Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/chatSessionMetadataStoreImpl.ts
13405 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 * as vscode from 'vscode';
7
import { Uri } from 'vscode';
8
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
9
import { createDirectoryIfNotExists, IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';
10
import { ILogService } from '../../../../platform/log/common/logService';
11
import { findLast } from '../../../../util/vs/base/common/arraysFind';
12
import { SequencerByKey, ThrottledDelayer } from '../../../../util/vs/base/common/async';
13
import { Disposable } from '../../../../util/vs/base/common/lifecycle';
14
import { dirname } from '../../../../util/vs/base/common/resources';
15
import { ChatSessionMetadataFile, IChatSessionMetadataStore, RepositoryProperties, RequestDetails, WorkspaceFolderEntry } from '../../common/chatSessionMetadataStore';
16
import { ChatSessionWorktreeProperties } from '../../common/chatSessionWorktreeService';
17
import { isUntitledSessionId } from '../../common/utils';
18
import { IWorkspaceInfo } from '../../common/workspaceInfo';
19
import { getCopilotBulkMetadataFile, getCopilotCLISessionDir } from '../../copilotcli/node/cliHelpers';
20
import { ICopilotCLIAgents } from '../../copilotcli/node/copilotCli';
21
22
// const WORKSPACE_FOLDER_MEMENTO_KEY = 'github.copilot.cli.sessionWorkspaceFolders';
23
// const WORKTREE_MEMENTO_KEY = 'github.copilot.cli.sessionWorktrees';
24
const LEGACY_BULK_METADATA_FILENAME = 'copilotcli.session.metadata.json';
25
const LEGACY_BULK_MIGRATED_KEY = 'github.copilot.cli.legacyBulkMigrated';
26
const REQUEST_MAPPING_FILENAME = 'vscode.requests.metadata.json';
27
28
/**
29
* Maximum number of sessions kept in the shared bulk metadata cache file
30
* (`~/.copilot/vscode.session.metadata.cache.json`). Older entries (by `modified`)
31
* are evicted from the file but remain available via the per-session metadata files
32
* (`~/.copilot/session-state/{id}/vscode.metadata.json`) and the JSONL worktree index.
33
*/
34
const MAX_BULK_STORAGE_ENTRIES = 1000;
35
36
/** Single-key sequencer key used to serialize bulk-file flush against {@link refresh}. */
37
const BULK_SEQUENCER_KEY = 'bulk';
38
39
export class ChatSessionMetadataStore extends Disposable implements IChatSessionMetadataStore {
40
declare _serviceBrand: undefined;
41
42
/**
43
* In-memory mirror of the bulk metadata file plus on-demand entries hydrated by
44
* {@link getSessionMetadata}. Always retains everything it has seen; only the on-disk
45
* file is trimmed to {@link MAX_BULK_STORAGE_ENTRIES}.
46
*/
47
private _cache: Record<string, ChatSessionMetadataFile> = {};
48
49
/** Session ID → indexed path and kind, for reverse-lookup cleanup. */
50
private readonly _sessionFolderEntry = new Map<string, { path: string; kind: 'worktree' | 'folder' }>();
51
/** Folder path → set of session IDs (worktree path or workspace folder path). */
52
private readonly _folderToSessions = new Map<string, Set<string>>();
53
54
/** Path of the shared bulk metadata cache file in `~/.copilot/`. */
55
private readonly _cacheFile = Uri.file(getCopilotBulkMetadataFile());
56
57
/**
58
* Single-promise gate. Initially set to `initializeStorage()`; {@link refresh} chains
59
* a {@link reloadBulkFromDisk} call onto it so concurrent refreshes collapse to at
60
* most one in-flight + one pending. Reads and writes both `await` this so they queue
61
* behind any in-flight refresh.
62
*/
63
private _ready: Promise<void>;
64
65
private readonly _updateStorageDebouncer = this._register(new ThrottledDelayer<void>(1_000));
66
private readonly _requestMappingWriteSequencer = new SequencerByKey<string>();
67
private readonly _metadataWriteSequencer = new SequencerByKey<string>();
68
/** Serializes bulk-file flush against {@link reloadBulkFromDisk}. */
69
private readonly _bulkSequencer = new SequencerByKey<string>();
70
71
constructor(
72
@IFileSystemService private readonly fileSystemService: IFileSystemService,
73
@ILogService private readonly logService: ILogService,
74
@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,
75
@ICopilotCLIAgents private readonly copilotCLIAgents: ICopilotCLIAgents,
76
) {
77
super();
78
79
this._ready = this.initializeStorage();
80
this._ready.catch(error => {
81
this.logService.error('[ChatSessionMetadataStore] Initialization failed: ', error);
82
});
83
}
84
85
public refresh(): Promise<void> {
86
// Chain onto the existing `_ready` — concurrent calls collapse to at most one
87
// in-flight + one pending. `.catch(() => undefined)` ensures a failed prior
88
// step does not poison subsequent reads/writes.
89
this._ready = this._ready.catch(() => undefined).then(() => this.reloadBulkFromDisk());
90
return this._ready;
91
}
92
93
public getSessionIdsForFolder(folder: vscode.Uri): string[] {
94
return Array.from(this._folderToSessions.get(folder.fsPath) ?? []);
95
}
96
97
public getWorktreeSessions(folder: vscode.Uri): string[] {
98
const sessions = this._folderToSessions.get(folder.fsPath);
99
if (!sessions) {
100
return [];
101
}
102
const result: string[] = [];
103
for (const sessionId of sessions) {
104
if (this._sessionFolderEntry.get(sessionId)?.kind === 'worktree') {
105
result.push(sessionId);
106
}
107
}
108
return result;
109
}
110
111
/**
112
* Maintains {@link _sessionFolderEntry} and {@link _folderToSessions} so
113
* that {@link getSessionIdsForFolder} and {@link getWorktreeSessions}
114
* are O(1) lookups instead of full-cache scans.
115
*/
116
private _updateFolderIndex(sessionId: string, metadata: ChatSessionMetadataFile | undefined): void {
117
// Remove old entry
118
const old = this._sessionFolderEntry.get(sessionId);
119
if (old) {
120
const set = this._folderToSessions.get(old.path);
121
if (set) {
122
set.delete(sessionId);
123
if (set.size === 0) {
124
this._folderToSessions.delete(old.path);
125
}
126
}
127
this._sessionFolderEntry.delete(sessionId);
128
}
129
130
if (!metadata) {
131
return;
132
}
133
134
// Prefer worktree path over workspace folder path
135
const worktreePath = metadata.worktreeProperties?.worktreePath;
136
const folderPath = metadata.workspaceFolder?.folderPath;
137
const path = worktreePath ?? folderPath;
138
if (!path) {
139
return;
140
}
141
142
const kind: 'worktree' | 'folder' = worktreePath ? 'worktree' : 'folder';
143
this._sessionFolderEntry.set(sessionId, { path, kind });
144
let set = this._folderToSessions.get(path);
145
if (!set) {
146
set = new Set();
147
this._folderToSessions.set(path, set);
148
}
149
set.add(sessionId);
150
}
151
152
private async initializeStorage(): Promise<void> {
153
// One-time migration from the legacy per-install bulk file in
154
// globalStorageUri to the shared `~/.copilot/` location.
155
await this.migrateLegacyBulkFile();
156
157
this._cache = await this.getGlobalStorageData().catch(() => ({} as Record<string, ChatSessionMetadataFile>));
158
// In case user closed vscode early or we couldn't save the session information for some reason.
159
for (const [sessionId, metadata] of Object.entries(this._cache)) {
160
if (sessionId.startsWith('untitled-')) {
161
delete this._cache[sessionId];
162
continue;
163
}
164
if (!(metadata.workspaceFolder || metadata.worktreeProperties || metadata.additionalWorkspaces?.length)) {
165
// invalid data, we don't need this in our cache.
166
delete this._cache[sessionId];
167
}
168
}
169
170
// Build folder index from the cleaned cache.
171
for (const [sessionId, metadata] of Object.entries(this._cache)) {
172
this._updateFolderIndex(sessionId, metadata);
173
}
174
175
// this.extensionContext.globalState.update(WORKTREE_MEMENTO_KEY, undefined);
176
// this.extensionContext.globalState.update(WORKSPACE_FOLDER_MEMENTO_KEY, undefined);
177
}
178
179
public getMetadataFileUri(sessionId: string): vscode.Uri {
180
return Uri.joinPath(Uri.file(getCopilotCLISessionDir(sessionId)), 'vscode.metadata.json');
181
}
182
183
private getRequestMappingFileUri(sessionId: string): vscode.Uri {
184
return Uri.joinPath(Uri.file(getCopilotCLISessionDir(sessionId)), REQUEST_MAPPING_FILENAME);
185
}
186
187
async deleteSessionMetadata(sessionId: string): Promise<void> {
188
await this._ready;
189
if (sessionId in this._cache) {
190
delete this._cache[sessionId];
191
this._updateFolderIndex(sessionId, undefined);
192
const data = await this.getGlobalStorageData().catch(() => ({} as Record<string, ChatSessionMetadataFile>));
193
delete data[sessionId];
194
await this.writeToGlobalStorage(data);
195
}
196
try {
197
await Promise.allSettled([
198
this.fileSystemService.delete(this.getMetadataFileUri(sessionId)),
199
this.fileSystemService.delete(this.getRequestMappingFileUri(sessionId))
200
]);
201
} catch {
202
// File may not exist, ignore.
203
}
204
}
205
206
private async updateMetadataFields(sessionId: string, fields: Partial<ChatSessionMetadataFile>): Promise<void> {
207
if (isUntitledSessionId(sessionId)) {
208
return;
209
}
210
await this._ready;
211
// Optimistically update in-memory cache so callers in the same process observe
212
// the change immediately. We pass only the partial `fields` to
213
// `updateSessionMetadata` — that method reads fresh from disk and merges, so it
214
// cannot stomp fields written by other processes (Step 3b: stale-cache fix).
215
const existing = this._cache[sessionId] ?? {};
216
this._cache[sessionId] = { ...existing, ...fields };
217
this._updateFolderIndex(sessionId, this._cache[sessionId]);
218
await this.updateSessionMetadata(sessionId, fields);
219
this.updateGlobalStorage();
220
}
221
222
async storeWorktreeInfo(sessionId: string, properties: ChatSessionWorktreeProperties): Promise<void> {
223
await this.updateMetadataFields(sessionId, { worktreeProperties: properties });
224
}
225
226
async storeWorkspaceFolderInfo(sessionId: string, entry: WorkspaceFolderEntry): Promise<void> {
227
await this.updateMetadataFields(sessionId, { workspaceFolder: entry });
228
}
229
230
async storeRepositoryProperties(sessionId: string, properties: RepositoryProperties): Promise<void> {
231
await this.updateMetadataFields(sessionId, { repositoryProperties: properties });
232
}
233
234
async getRepositoryProperties(sessionId: string): Promise<RepositoryProperties | undefined> {
235
const metadata = await this.getSessionMetadata(sessionId);
236
return metadata?.repositoryProperties;
237
}
238
239
async getWorktreeProperties(sessionId: string): Promise<ChatSessionWorktreeProperties | undefined> {
240
await this._ready;
241
const metadata = await this.getSessionMetadata(sessionId);
242
return metadata?.worktreeProperties;
243
}
244
245
async getSessionWorkspaceFolder(sessionId: string): Promise<vscode.Uri | undefined> {
246
const metadata = await this.getSessionMetadata(sessionId);
247
if (!metadata) {
248
return undefined;
249
}
250
// Prefer worktree properties when both exist (this isn't possible, but if this happens).
251
if (metadata.worktreeProperties) {
252
return undefined;
253
}
254
return metadata.workspaceFolder?.folderPath ? Uri.file(metadata.workspaceFolder.folderPath) : undefined;
255
}
256
257
async getSessionWorkspaceFolderEntry(sessionId: string): Promise<WorkspaceFolderEntry | undefined> {
258
const metadata = await this.getSessionMetadata(sessionId);
259
if (!metadata) {
260
return undefined;
261
}
262
return metadata.workspaceFolder;
263
}
264
265
async getAdditionalWorkspaces(sessionId: string): Promise<IWorkspaceInfo[]> {
266
const metadata = await this.getSessionMetadata(sessionId);
267
if (!metadata?.additionalWorkspaces?.length) {
268
return [];
269
}
270
return metadata.additionalWorkspaces.map(ws => ({
271
folder: !ws.worktreeProperties && ws.workspaceFolder?.folderPath ? Uri.file(ws.workspaceFolder.folderPath) : undefined,
272
repository: ws.worktreeProperties?.repositoryPath ? Uri.file(ws.worktreeProperties.repositoryPath) : undefined,
273
repositoryProperties: undefined,
274
worktree: ws.worktreeProperties?.worktreePath ? Uri.file(ws.worktreeProperties.worktreePath) : undefined,
275
worktreeProperties: ws.worktreeProperties,
276
}));
277
}
278
279
async setAdditionalWorkspaces(sessionId: string, workspaces: IWorkspaceInfo[]): Promise<void> {
280
const additionalWorkspaces = workspaces.map(ws => ({
281
worktreeProperties: ws.worktreeProperties,
282
workspaceFolder: !ws.worktreeProperties && ws.folder ? { folderPath: ws.folder.fsPath, timestamp: Date.now() } : undefined,
283
}));
284
await this.updateMetadataFields(sessionId, { additionalWorkspaces });
285
}
286
287
async getSessionFirstUserMessage(sessionId: string): Promise<string | undefined> {
288
const metadata = await this.getSessionMetadata(sessionId);
289
return metadata?.firstUserMessage;
290
}
291
292
async getCustomTitle(sessionId: string): Promise<string | undefined> {
293
const metadata = await this.getSessionMetadata(sessionId);
294
return metadata?.customTitle;
295
}
296
297
async setCustomTitle(sessionId: string, title: string): Promise<void> {
298
await this.updateMetadataFields(sessionId, { customTitle: title });
299
}
300
301
async setSessionFirstUserMessage(sessionId: string, message: string): Promise<void> {
302
await this.updateMetadataFields(sessionId, { firstUserMessage: message });
303
}
304
305
async getRequestDetails(sessionId: string): Promise<RequestDetails[]> {
306
await this._ready;
307
const fileUri = this.getRequestMappingFileUri(sessionId);
308
try {
309
const content = await this.fileSystemService.readFile(fileUri);
310
return JSON.parse(new TextDecoder().decode(content)) as RequestDetails[];
311
} catch {
312
return [];
313
}
314
}
315
316
async updateRequestDetails(sessionId: string, details: (Partial<RequestDetails> & { vscodeRequestId: string })[]): Promise<void> {
317
await this._ready;
318
if (isUntitledSessionId(sessionId)) {
319
return;
320
}
321
322
await this._requestMappingWriteSequencer.queue(sessionId, async () => {
323
const existing = await this.getRequestDetails(sessionId);
324
325
for (const item of details) {
326
const existingDetails = existing.find(e => e.vscodeRequestId === item.vscodeRequestId);
327
if (existingDetails) {
328
// Ensure we don't override any existing data.
329
const defined = Object.fromEntries(Object.entries(item).filter(([, v]) => v !== undefined));
330
Object.assign(existingDetails, defined);
331
} else {
332
const newEntry = { ...item, toolIdEditMap: item.toolIdEditMap ?? {} };
333
existing.push(newEntry);
334
}
335
}
336
await this.writeRequestDetails(sessionId, existing);
337
});
338
}
339
340
async getSessionAgent(sessionId: string): Promise<string | undefined> {
341
const details = await this.getRequestDetails(sessionId);
342
return findLast(details, d => !!d.agentId)?.agentId ?? this.copilotCLIAgents.getSessionAgent(sessionId);
343
}
344
345
private async writeRequestDetails(sessionId: string, details: RequestDetails[]): Promise<void> {
346
await this._ready;
347
if (isUntitledSessionId(sessionId)) {
348
return;
349
}
350
const fileUri = this.getRequestMappingFileUri(sessionId);
351
const dirUri = dirname(fileUri);
352
await createDirectoryIfNotExists(this.fileSystemService, dirUri);
353
const content = new TextEncoder().encode(JSON.stringify(details, null, 2));
354
await this.fileSystemService.writeFile(fileUri, content);
355
this.logService.trace(`[ChatSessionMetadataStore] Wrote request details for session ${sessionId}`);
356
}
357
358
async storeForkedSessionMetadata(sourceSessionId: string, targetSessionId: string, customTitle: string): Promise<void> {
359
await this._ready;
360
const sourceMetadata = await this.getSessionMetadata(sourceSessionId);
361
const forkedMetadata: ChatSessionMetadataFile = {
362
...sourceMetadata,
363
customTitle,
364
writtenToDisc: true,
365
parentSessionId: sourceSessionId,
366
origin: 'vscode',
367
kind: 'forked',
368
};
369
await this.updateMetadataFields(targetSessionId, forkedMetadata);
370
}
371
372
public async setSessionOrigin(sessionId: string): Promise<void> {
373
await this._ready;
374
await this.updateMetadataFields(sessionId, { origin: 'vscode' });
375
}
376
377
public async getSessionOrigin(sessionId: string): Promise<'vscode' | 'other'> {
378
const metadata = await this.getSessionMetadata(sessionId, false);
379
if (!metadata || Object.keys(metadata).length === 0) {
380
// We always store some metadata
381
return 'other';
382
}
383
if (metadata.origin) {
384
return metadata.origin;
385
}
386
// Older sessions, guess.
387
if (metadata?.repositoryProperties || metadata?.worktreeProperties || metadata?.workspaceFolder) {
388
return 'vscode';
389
}
390
return 'other';
391
}
392
393
public async setSessionParentId(sessionId: string, parentSessionId: string): Promise<void> {
394
await this._ready;
395
await this.updateMetadataFields(sessionId, { parentSessionId, kind: 'sub-session' });
396
}
397
398
public async getSessionParentId(sessionId: string): Promise<string | undefined> {
399
const metadata = await this.getSessionMetadata(sessionId, false);
400
return metadata?.parentSessionId;
401
}
402
403
private async getSessionMetadata(sessionId: string, createMetadataFileIfNotFound = true): Promise<ChatSessionMetadataFile | undefined> {
404
if (isUntitledSessionId(sessionId)) {
405
return undefined;
406
}
407
await this._ready;
408
if (sessionId in this._cache) {
409
return this._cache[sessionId];
410
}
411
412
const metadata = await this.readSessionMetadataFile(sessionId);
413
if (metadata) {
414
this._cache[sessionId] = metadata;
415
this._updateFolderIndex(sessionId, metadata);
416
return metadata;
417
}
418
419
// So we don't try again.
420
this._cache[sessionId] = {};
421
if (createMetadataFileIfNotFound) {
422
await this.updateSessionMetadata(sessionId, { origin: 'other' });
423
this.updateGlobalStorage();
424
}
425
return undefined;
426
}
427
428
/** Reads a per-session metadata file directly. Returns `undefined` if it doesn't exist or is invalid. */
429
private async readSessionMetadataFile(sessionId: string): Promise<ChatSessionMetadataFile | undefined> {
430
try {
431
const fileUri = this.getMetadataFileUri(sessionId);
432
const content = await this.fileSystemService.readFile(fileUri);
433
return JSON.parse(new TextDecoder().decode(content)) as ChatSessionMetadataFile;
434
} catch {
435
return undefined;
436
}
437
}
438
439
private async updateSessionMetadata(sessionId: string, updates: Partial<ChatSessionMetadataFile>, createDirectoryIfNotFound = true): Promise<void> {
440
if (isUntitledSessionId(sessionId)) {
441
// Don't write metadata for untitled sessions, as they are temporary and can be created in large numbers.
442
return;
443
}
444
445
await this._metadataWriteSequencer.queue(sessionId, async () => {
446
const fileUri = this.getMetadataFileUri(sessionId);
447
const dirUri = dirname(fileUri);
448
449
// Try to read existing file first (will succeed 99% of the time).
450
// This preserves data written by other processes when merging.
451
let existing: ChatSessionMetadataFile = {};
452
let diskFileExisted = true;
453
try {
454
const rawContent = await this.fileSystemService.readFile(fileUri);
455
existing = JSON.parse(new TextDecoder().decode(rawContent));
456
} catch {
457
diskFileExisted = false;
458
// File doesn't exist yet — check if the directory exists.
459
try {
460
await this.fileSystemService.stat(dirUri);
461
} catch {
462
if (!createDirectoryIfNotFound) {
463
// Lets not delete the session from our storage, but mark it as written to session state so that we won't try to write to session state again and again.
464
this._cache[sessionId] = { ...updates, writtenToDisc: true };
465
this._updateFolderIndex(sessionId, this._cache[sessionId]);
466
this.updateGlobalStorage();
467
return;
468
}
469
await this.fileSystemService.createDirectory(dirUri);
470
}
471
}
472
473
// Merge order: cache (locally-known fields not yet flushed to disk)
474
// → disk existing (cross-process writes win over stale cache, Step 3b)
475
// → explicit `metadata` fields from this call (caller wins).
476
// `undefined` values in `metadata` delete the corresponding key.
477
const cacheExisting = diskFileExisted ? {} : (this._cache[sessionId] ?? {});
478
const merged: ChatSessionMetadataFile = { ...cacheExisting, ...existing };
479
for (const [key, value] of Object.entries(updates)) {
480
if (value === undefined) {
481
delete (merged as Record<string, unknown>)[key];
482
} else {
483
(merged as Record<string, unknown>)[key] = value;
484
}
485
}
486
487
// Stamp timestamps. `created` is set only on first write; `modified` is
488
// bumped on every write.
489
const now = Date.now();
490
merged.modified = now;
491
if (merged.created === undefined) {
492
merged.created = now;
493
}
494
495
const content = new TextEncoder().encode(JSON.stringify(merged, null, 2));
496
await this.fileSystemService.writeFile(fileUri, content);
497
498
this._cache[sessionId] = { ...merged, writtenToDisc: true };
499
this._updateFolderIndex(sessionId, this._cache[sessionId]);
500
this.updateGlobalStorage();
501
this.logService.trace(`[ChatSessionMetadataStore] Wrote metadata for session ${sessionId}`);
502
});
503
}
504
505
private async getGlobalStorageData() {
506
const data = await this.fileSystemService.readFile(this._cacheFile);
507
return JSON.parse(new TextDecoder().decode(data)) as Record<string, ChatSessionMetadataFile>;
508
}
509
510
private updateGlobalStorage() {
511
this._updateStorageDebouncer.trigger(() => this.updateGlobalStorageImpl()).catch(() => { /* expected on dispose */ });
512
}
513
514
private async updateGlobalStorageImpl() {
515
try {
516
// Serialize against `refresh()` and other bulk-file flushes via the shared
517
// single-key sequencer. Inside the queue we re-read the on-disk file and
518
// merge it with the in-memory cache using last-modified-wins semantics so
519
// concurrent writers in another process do not lose data.
520
await this._bulkSequencer.queue(BULK_SEQUENCER_KEY, async () => {
521
const data: Record<string, ChatSessionMetadataFile> = { ...this._cache };
522
try {
523
const storageData = await this.getGlobalStorageData();
524
for (const [sessionId, diskEntry] of Object.entries(storageData)) {
525
const local = data[sessionId];
526
if (!local) {
527
data[sessionId] = diskEntry;
528
this._cache[sessionId] = diskEntry;
529
this._updateFolderIndex(sessionId, diskEntry);
530
continue;
531
}
532
const localModified = local.modified ?? 0;
533
const diskModified = diskEntry.modified ?? 0;
534
if (diskModified > localModified) {
535
data[sessionId] = diskEntry;
536
this._cache[sessionId] = diskEntry;
537
this._updateFolderIndex(sessionId, diskEntry);
538
}
539
}
540
} catch {
541
//
542
}
543
await this.writeToGlobalStorage(data);
544
});
545
} catch (error) {
546
this.logService.error('[ChatSessionMetadataStore] Failed to update global storage: ', error);
547
}
548
}
549
550
private async writeToGlobalStorage(allMetadata: Record<string, ChatSessionMetadataFile>): Promise<void> {
551
// Make a shallow copy and trim to the top MAX_BULK_STORAGE_ENTRIES by `modified` desc.
552
// The in-memory `_cache` is unaffected — only the on-disk file is bounded.
553
// Per-session files in `~/.copilot/session-state/{id}/vscode.metadata.json` remain
554
// the source of truth for evicted entries.
555
const entries = Object.entries(allMetadata);
556
let toWrite: Record<string, ChatSessionMetadataFile>;
557
if (entries.length <= MAX_BULK_STORAGE_ENTRIES) {
558
toWrite = { ...allMetadata };
559
} else {
560
entries.sort(([, a], [, b]) => (b.modified ?? 0) - (a.modified ?? 0));
561
toWrite = Object.fromEntries(entries.slice(0, MAX_BULK_STORAGE_ENTRIES));
562
}
563
564
const dirUri = dirname(this._cacheFile);
565
try {
566
await this.fileSystemService.stat(dirUri);
567
} catch {
568
await this.fileSystemService.createDirectory(dirUri);
569
}
570
571
const content = new TextEncoder().encode(JSON.stringify(toWrite, null, 2));
572
await this.fileSystemService.writeFile(this._cacheFile, content);
573
this.logService.trace(`[ChatSessionMetadataStore] Wrote bulk metadata file with ${Object.keys(toWrite).length} session(s)`);
574
}
575
576
/**
577
* Re-reads the shared bulk file from disk and merges into `_cache` using
578
* last-modified-wins. Runs inside the bulk sequencer so it is serialized
579
* against {@link updateGlobalStorageImpl}. Never drops in-memory entries.
580
*/
581
private async reloadBulkFromDisk(): Promise<void> {
582
return this._bulkSequencer.queue(BULK_SEQUENCER_KEY, async () => {
583
let onDisk: Record<string, ChatSessionMetadataFile>;
584
try {
585
onDisk = await this.getGlobalStorageData();
586
} catch {
587
return;
588
}
589
for (const [id, diskEntry] of Object.entries(onDisk)) {
590
const local = this._cache[id];
591
if (!local) {
592
this._cache[id] = diskEntry;
593
this._updateFolderIndex(id, diskEntry);
594
continue;
595
}
596
const localModified = local.modified ?? 0;
597
const diskModified = diskEntry.modified ?? 0;
598
if (diskModified > localModified) {
599
this._cache[id] = diskEntry;
600
this._updateFolderIndex(id, diskEntry);
601
}
602
}
603
});
604
}
605
606
/**
607
* Merges the per-install legacy bulk file (`globalStorageUri/copilotcli/…`) into
608
* the shared `~/.copilot/` bulk file using last-modified-wins. This handles:
609
* - First-run: shared file missing → copy legacy content into the shared file.
610
* - Late-joiner: Process A already created the shared file → merge so entries
611
* unique to this install are not lost.
612
* - No legacy file: nothing to do.
613
*/
614
private async migrateLegacyBulkFile(): Promise<void> {
615
// Skip if this install already migrated.
616
if (this.extensionContext.globalState.get<boolean>(LEGACY_BULK_MIGRATED_KEY)) {
617
return;
618
}
619
620
const legacyCacheFile = Uri.joinPath(this.extensionContext.globalStorageUri, 'copilotcli', LEGACY_BULK_METADATA_FILENAME);
621
let legacyData: Record<string, ChatSessionMetadataFile>;
622
try {
623
const raw = await this.fileSystemService.readFile(legacyCacheFile);
624
legacyData = JSON.parse(new TextDecoder().decode(raw));
625
} catch {
626
// No legacy file — mark as migrated so we don't retry.
627
await this.extensionContext.globalState.update(LEGACY_BULK_MIGRATED_KEY, true);
628
return;
629
}
630
631
try {
632
await createDirectoryIfNotExists(this.fileSystemService, dirname(this._cacheFile));
633
634
// Try to read the shared file (may or may not exist yet).
635
let sharedData: Record<string, ChatSessionMetadataFile> = {};
636
try {
637
const raw = await this.fileSystemService.readFile(this._cacheFile);
638
sharedData = JSON.parse(new TextDecoder().decode(raw));
639
} catch {
640
// Shared file doesn't exist yet — start empty.
641
}
642
643
// Merge legacy into shared using last-modified-wins.
644
let merged = false;
645
for (const [id, legacyEntry] of Object.entries(legacyData)) {
646
const sharedEntry = sharedData[id];
647
if (!sharedEntry) {
648
sharedData[id] = legacyEntry;
649
merged = true;
650
} else {
651
const sharedModified = sharedEntry.modified ?? 0;
652
const legacyModified = legacyEntry.modified ?? 0;
653
if (legacyModified > sharedModified) {
654
sharedData[id] = legacyEntry;
655
merged = true;
656
}
657
}
658
}
659
660
if (merged) {
661
const content = new TextEncoder().encode(JSON.stringify(sharedData, null, 2));
662
await this.fileSystemService.writeFile(this._cacheFile, content);
663
}
664
665
// Mark as migrated so subsequent startups skip this path.
666
await this.extensionContext.globalState.update(LEGACY_BULK_MIGRATED_KEY, true);
667
this.logService.info('[ChatSessionMetadataStore] Migrated legacy bulk metadata file to ~/.copilot/');
668
} catch (err) {
669
this.logService.error('[ChatSessionMetadataStore] Failed to migrate legacy bulk file: ', err);
670
}
671
}
672
}
673
674