Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/chatSessionMetadataStoreImpl.ts
13405 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 * as vscode from 'vscode';6import { Uri } from 'vscode';7import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';8import { createDirectoryIfNotExists, IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';9import { ILogService } from '../../../../platform/log/common/logService';10import { findLast } from '../../../../util/vs/base/common/arraysFind';11import { SequencerByKey, ThrottledDelayer } from '../../../../util/vs/base/common/async';12import { Disposable } from '../../../../util/vs/base/common/lifecycle';13import { dirname } from '../../../../util/vs/base/common/resources';14import { ChatSessionMetadataFile, IChatSessionMetadataStore, RepositoryProperties, RequestDetails, WorkspaceFolderEntry } from '../../common/chatSessionMetadataStore';15import { ChatSessionWorktreeProperties } from '../../common/chatSessionWorktreeService';16import { isUntitledSessionId } from '../../common/utils';17import { IWorkspaceInfo } from '../../common/workspaceInfo';18import { getCopilotBulkMetadataFile, getCopilotCLISessionDir } from '../../copilotcli/node/cliHelpers';19import { ICopilotCLIAgents } from '../../copilotcli/node/copilotCli';2021// const WORKSPACE_FOLDER_MEMENTO_KEY = 'github.copilot.cli.sessionWorkspaceFolders';22// const WORKTREE_MEMENTO_KEY = 'github.copilot.cli.sessionWorktrees';23const LEGACY_BULK_METADATA_FILENAME = 'copilotcli.session.metadata.json';24const LEGACY_BULK_MIGRATED_KEY = 'github.copilot.cli.legacyBulkMigrated';25const REQUEST_MAPPING_FILENAME = 'vscode.requests.metadata.json';2627/**28* Maximum number of sessions kept in the shared bulk metadata cache file29* (`~/.copilot/vscode.session.metadata.cache.json`). Older entries (by `modified`)30* are evicted from the file but remain available via the per-session metadata files31* (`~/.copilot/session-state/{id}/vscode.metadata.json`) and the JSONL worktree index.32*/33const MAX_BULK_STORAGE_ENTRIES = 1000;3435/** Single-key sequencer key used to serialize bulk-file flush against {@link refresh}. */36const BULK_SEQUENCER_KEY = 'bulk';3738export class ChatSessionMetadataStore extends Disposable implements IChatSessionMetadataStore {39declare _serviceBrand: undefined;4041/**42* In-memory mirror of the bulk metadata file plus on-demand entries hydrated by43* {@link getSessionMetadata}. Always retains everything it has seen; only the on-disk44* file is trimmed to {@link MAX_BULK_STORAGE_ENTRIES}.45*/46private _cache: Record<string, ChatSessionMetadataFile> = {};4748/** Session ID → indexed path and kind, for reverse-lookup cleanup. */49private readonly _sessionFolderEntry = new Map<string, { path: string; kind: 'worktree' | 'folder' }>();50/** Folder path → set of session IDs (worktree path or workspace folder path). */51private readonly _folderToSessions = new Map<string, Set<string>>();5253/** Path of the shared bulk metadata cache file in `~/.copilot/`. */54private readonly _cacheFile = Uri.file(getCopilotBulkMetadataFile());5556/**57* Single-promise gate. Initially set to `initializeStorage()`; {@link refresh} chains58* a {@link reloadBulkFromDisk} call onto it so concurrent refreshes collapse to at59* most one in-flight + one pending. Reads and writes both `await` this so they queue60* behind any in-flight refresh.61*/62private _ready: Promise<void>;6364private readonly _updateStorageDebouncer = this._register(new ThrottledDelayer<void>(1_000));65private readonly _requestMappingWriteSequencer = new SequencerByKey<string>();66private readonly _metadataWriteSequencer = new SequencerByKey<string>();67/** Serializes bulk-file flush against {@link reloadBulkFromDisk}. */68private readonly _bulkSequencer = new SequencerByKey<string>();6970constructor(71@IFileSystemService private readonly fileSystemService: IFileSystemService,72@ILogService private readonly logService: ILogService,73@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,74@ICopilotCLIAgents private readonly copilotCLIAgents: ICopilotCLIAgents,75) {76super();7778this._ready = this.initializeStorage();79this._ready.catch(error => {80this.logService.error('[ChatSessionMetadataStore] Initialization failed: ', error);81});82}8384public refresh(): Promise<void> {85// Chain onto the existing `_ready` — concurrent calls collapse to at most one86// in-flight + one pending. `.catch(() => undefined)` ensures a failed prior87// step does not poison subsequent reads/writes.88this._ready = this._ready.catch(() => undefined).then(() => this.reloadBulkFromDisk());89return this._ready;90}9192public getSessionIdsForFolder(folder: vscode.Uri): string[] {93return Array.from(this._folderToSessions.get(folder.fsPath) ?? []);94}9596public getWorktreeSessions(folder: vscode.Uri): string[] {97const sessions = this._folderToSessions.get(folder.fsPath);98if (!sessions) {99return [];100}101const result: string[] = [];102for (const sessionId of sessions) {103if (this._sessionFolderEntry.get(sessionId)?.kind === 'worktree') {104result.push(sessionId);105}106}107return result;108}109110/**111* Maintains {@link _sessionFolderEntry} and {@link _folderToSessions} so112* that {@link getSessionIdsForFolder} and {@link getWorktreeSessions}113* are O(1) lookups instead of full-cache scans.114*/115private _updateFolderIndex(sessionId: string, metadata: ChatSessionMetadataFile | undefined): void {116// Remove old entry117const old = this._sessionFolderEntry.get(sessionId);118if (old) {119const set = this._folderToSessions.get(old.path);120if (set) {121set.delete(sessionId);122if (set.size === 0) {123this._folderToSessions.delete(old.path);124}125}126this._sessionFolderEntry.delete(sessionId);127}128129if (!metadata) {130return;131}132133// Prefer worktree path over workspace folder path134const worktreePath = metadata.worktreeProperties?.worktreePath;135const folderPath = metadata.workspaceFolder?.folderPath;136const path = worktreePath ?? folderPath;137if (!path) {138return;139}140141const kind: 'worktree' | 'folder' = worktreePath ? 'worktree' : 'folder';142this._sessionFolderEntry.set(sessionId, { path, kind });143let set = this._folderToSessions.get(path);144if (!set) {145set = new Set();146this._folderToSessions.set(path, set);147}148set.add(sessionId);149}150151private async initializeStorage(): Promise<void> {152// One-time migration from the legacy per-install bulk file in153// globalStorageUri to the shared `~/.copilot/` location.154await this.migrateLegacyBulkFile();155156this._cache = await this.getGlobalStorageData().catch(() => ({} as Record<string, ChatSessionMetadataFile>));157// In case user closed vscode early or we couldn't save the session information for some reason.158for (const [sessionId, metadata] of Object.entries(this._cache)) {159if (sessionId.startsWith('untitled-')) {160delete this._cache[sessionId];161continue;162}163if (!(metadata.workspaceFolder || metadata.worktreeProperties || metadata.additionalWorkspaces?.length)) {164// invalid data, we don't need this in our cache.165delete this._cache[sessionId];166}167}168169// Build folder index from the cleaned cache.170for (const [sessionId, metadata] of Object.entries(this._cache)) {171this._updateFolderIndex(sessionId, metadata);172}173174// this.extensionContext.globalState.update(WORKTREE_MEMENTO_KEY, undefined);175// this.extensionContext.globalState.update(WORKSPACE_FOLDER_MEMENTO_KEY, undefined);176}177178public getMetadataFileUri(sessionId: string): vscode.Uri {179return Uri.joinPath(Uri.file(getCopilotCLISessionDir(sessionId)), 'vscode.metadata.json');180}181182private getRequestMappingFileUri(sessionId: string): vscode.Uri {183return Uri.joinPath(Uri.file(getCopilotCLISessionDir(sessionId)), REQUEST_MAPPING_FILENAME);184}185186async deleteSessionMetadata(sessionId: string): Promise<void> {187await this._ready;188if (sessionId in this._cache) {189delete this._cache[sessionId];190this._updateFolderIndex(sessionId, undefined);191const data = await this.getGlobalStorageData().catch(() => ({} as Record<string, ChatSessionMetadataFile>));192delete data[sessionId];193await this.writeToGlobalStorage(data);194}195try {196await Promise.allSettled([197this.fileSystemService.delete(this.getMetadataFileUri(sessionId)),198this.fileSystemService.delete(this.getRequestMappingFileUri(sessionId))199]);200} catch {201// File may not exist, ignore.202}203}204205private async updateMetadataFields(sessionId: string, fields: Partial<ChatSessionMetadataFile>): Promise<void> {206if (isUntitledSessionId(sessionId)) {207return;208}209await this._ready;210// Optimistically update in-memory cache so callers in the same process observe211// the change immediately. We pass only the partial `fields` to212// `updateSessionMetadata` — that method reads fresh from disk and merges, so it213// cannot stomp fields written by other processes (Step 3b: stale-cache fix).214const existing = this._cache[sessionId] ?? {};215this._cache[sessionId] = { ...existing, ...fields };216this._updateFolderIndex(sessionId, this._cache[sessionId]);217await this.updateSessionMetadata(sessionId, fields);218this.updateGlobalStorage();219}220221async storeWorktreeInfo(sessionId: string, properties: ChatSessionWorktreeProperties): Promise<void> {222await this.updateMetadataFields(sessionId, { worktreeProperties: properties });223}224225async storeWorkspaceFolderInfo(sessionId: string, entry: WorkspaceFolderEntry): Promise<void> {226await this.updateMetadataFields(sessionId, { workspaceFolder: entry });227}228229async storeRepositoryProperties(sessionId: string, properties: RepositoryProperties): Promise<void> {230await this.updateMetadataFields(sessionId, { repositoryProperties: properties });231}232233async getRepositoryProperties(sessionId: string): Promise<RepositoryProperties | undefined> {234const metadata = await this.getSessionMetadata(sessionId);235return metadata?.repositoryProperties;236}237238async getWorktreeProperties(sessionId: string): Promise<ChatSessionWorktreeProperties | undefined> {239await this._ready;240const metadata = await this.getSessionMetadata(sessionId);241return metadata?.worktreeProperties;242}243244async getSessionWorkspaceFolder(sessionId: string): Promise<vscode.Uri | undefined> {245const metadata = await this.getSessionMetadata(sessionId);246if (!metadata) {247return undefined;248}249// Prefer worktree properties when both exist (this isn't possible, but if this happens).250if (metadata.worktreeProperties) {251return undefined;252}253return metadata.workspaceFolder?.folderPath ? Uri.file(metadata.workspaceFolder.folderPath) : undefined;254}255256async getSessionWorkspaceFolderEntry(sessionId: string): Promise<WorkspaceFolderEntry | undefined> {257const metadata = await this.getSessionMetadata(sessionId);258if (!metadata) {259return undefined;260}261return metadata.workspaceFolder;262}263264async getAdditionalWorkspaces(sessionId: string): Promise<IWorkspaceInfo[]> {265const metadata = await this.getSessionMetadata(sessionId);266if (!metadata?.additionalWorkspaces?.length) {267return [];268}269return metadata.additionalWorkspaces.map(ws => ({270folder: !ws.worktreeProperties && ws.workspaceFolder?.folderPath ? Uri.file(ws.workspaceFolder.folderPath) : undefined,271repository: ws.worktreeProperties?.repositoryPath ? Uri.file(ws.worktreeProperties.repositoryPath) : undefined,272repositoryProperties: undefined,273worktree: ws.worktreeProperties?.worktreePath ? Uri.file(ws.worktreeProperties.worktreePath) : undefined,274worktreeProperties: ws.worktreeProperties,275}));276}277278async setAdditionalWorkspaces(sessionId: string, workspaces: IWorkspaceInfo[]): Promise<void> {279const additionalWorkspaces = workspaces.map(ws => ({280worktreeProperties: ws.worktreeProperties,281workspaceFolder: !ws.worktreeProperties && ws.folder ? { folderPath: ws.folder.fsPath, timestamp: Date.now() } : undefined,282}));283await this.updateMetadataFields(sessionId, { additionalWorkspaces });284}285286async getSessionFirstUserMessage(sessionId: string): Promise<string | undefined> {287const metadata = await this.getSessionMetadata(sessionId);288return metadata?.firstUserMessage;289}290291async getCustomTitle(sessionId: string): Promise<string | undefined> {292const metadata = await this.getSessionMetadata(sessionId);293return metadata?.customTitle;294}295296async setCustomTitle(sessionId: string, title: string): Promise<void> {297await this.updateMetadataFields(sessionId, { customTitle: title });298}299300async setSessionFirstUserMessage(sessionId: string, message: string): Promise<void> {301await this.updateMetadataFields(sessionId, { firstUserMessage: message });302}303304async getRequestDetails(sessionId: string): Promise<RequestDetails[]> {305await this._ready;306const fileUri = this.getRequestMappingFileUri(sessionId);307try {308const content = await this.fileSystemService.readFile(fileUri);309return JSON.parse(new TextDecoder().decode(content)) as RequestDetails[];310} catch {311return [];312}313}314315async updateRequestDetails(sessionId: string, details: (Partial<RequestDetails> & { vscodeRequestId: string })[]): Promise<void> {316await this._ready;317if (isUntitledSessionId(sessionId)) {318return;319}320321await this._requestMappingWriteSequencer.queue(sessionId, async () => {322const existing = await this.getRequestDetails(sessionId);323324for (const item of details) {325const existingDetails = existing.find(e => e.vscodeRequestId === item.vscodeRequestId);326if (existingDetails) {327// Ensure we don't override any existing data.328const defined = Object.fromEntries(Object.entries(item).filter(([, v]) => v !== undefined));329Object.assign(existingDetails, defined);330} else {331const newEntry = { ...item, toolIdEditMap: item.toolIdEditMap ?? {} };332existing.push(newEntry);333}334}335await this.writeRequestDetails(sessionId, existing);336});337}338339async getSessionAgent(sessionId: string): Promise<string | undefined> {340const details = await this.getRequestDetails(sessionId);341return findLast(details, d => !!d.agentId)?.agentId ?? this.copilotCLIAgents.getSessionAgent(sessionId);342}343344private async writeRequestDetails(sessionId: string, details: RequestDetails[]): Promise<void> {345await this._ready;346if (isUntitledSessionId(sessionId)) {347return;348}349const fileUri = this.getRequestMappingFileUri(sessionId);350const dirUri = dirname(fileUri);351await createDirectoryIfNotExists(this.fileSystemService, dirUri);352const content = new TextEncoder().encode(JSON.stringify(details, null, 2));353await this.fileSystemService.writeFile(fileUri, content);354this.logService.trace(`[ChatSessionMetadataStore] Wrote request details for session ${sessionId}`);355}356357async storeForkedSessionMetadata(sourceSessionId: string, targetSessionId: string, customTitle: string): Promise<void> {358await this._ready;359const sourceMetadata = await this.getSessionMetadata(sourceSessionId);360const forkedMetadata: ChatSessionMetadataFile = {361...sourceMetadata,362customTitle,363writtenToDisc: true,364parentSessionId: sourceSessionId,365origin: 'vscode',366kind: 'forked',367};368await this.updateMetadataFields(targetSessionId, forkedMetadata);369}370371public async setSessionOrigin(sessionId: string): Promise<void> {372await this._ready;373await this.updateMetadataFields(sessionId, { origin: 'vscode' });374}375376public async getSessionOrigin(sessionId: string): Promise<'vscode' | 'other'> {377const metadata = await this.getSessionMetadata(sessionId, false);378if (!metadata || Object.keys(metadata).length === 0) {379// We always store some metadata380return 'other';381}382if (metadata.origin) {383return metadata.origin;384}385// Older sessions, guess.386if (metadata?.repositoryProperties || metadata?.worktreeProperties || metadata?.workspaceFolder) {387return 'vscode';388}389return 'other';390}391392public async setSessionParentId(sessionId: string, parentSessionId: string): Promise<void> {393await this._ready;394await this.updateMetadataFields(sessionId, { parentSessionId, kind: 'sub-session' });395}396397public async getSessionParentId(sessionId: string): Promise<string | undefined> {398const metadata = await this.getSessionMetadata(sessionId, false);399return metadata?.parentSessionId;400}401402private async getSessionMetadata(sessionId: string, createMetadataFileIfNotFound = true): Promise<ChatSessionMetadataFile | undefined> {403if (isUntitledSessionId(sessionId)) {404return undefined;405}406await this._ready;407if (sessionId in this._cache) {408return this._cache[sessionId];409}410411const metadata = await this.readSessionMetadataFile(sessionId);412if (metadata) {413this._cache[sessionId] = metadata;414this._updateFolderIndex(sessionId, metadata);415return metadata;416}417418// So we don't try again.419this._cache[sessionId] = {};420if (createMetadataFileIfNotFound) {421await this.updateSessionMetadata(sessionId, { origin: 'other' });422this.updateGlobalStorage();423}424return undefined;425}426427/** Reads a per-session metadata file directly. Returns `undefined` if it doesn't exist or is invalid. */428private async readSessionMetadataFile(sessionId: string): Promise<ChatSessionMetadataFile | undefined> {429try {430const fileUri = this.getMetadataFileUri(sessionId);431const content = await this.fileSystemService.readFile(fileUri);432return JSON.parse(new TextDecoder().decode(content)) as ChatSessionMetadataFile;433} catch {434return undefined;435}436}437438private async updateSessionMetadata(sessionId: string, updates: Partial<ChatSessionMetadataFile>, createDirectoryIfNotFound = true): Promise<void> {439if (isUntitledSessionId(sessionId)) {440// Don't write metadata for untitled sessions, as they are temporary and can be created in large numbers.441return;442}443444await this._metadataWriteSequencer.queue(sessionId, async () => {445const fileUri = this.getMetadataFileUri(sessionId);446const dirUri = dirname(fileUri);447448// Try to read existing file first (will succeed 99% of the time).449// This preserves data written by other processes when merging.450let existing: ChatSessionMetadataFile = {};451let diskFileExisted = true;452try {453const rawContent = await this.fileSystemService.readFile(fileUri);454existing = JSON.parse(new TextDecoder().decode(rawContent));455} catch {456diskFileExisted = false;457// File doesn't exist yet — check if the directory exists.458try {459await this.fileSystemService.stat(dirUri);460} catch {461if (!createDirectoryIfNotFound) {462// 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.463this._cache[sessionId] = { ...updates, writtenToDisc: true };464this._updateFolderIndex(sessionId, this._cache[sessionId]);465this.updateGlobalStorage();466return;467}468await this.fileSystemService.createDirectory(dirUri);469}470}471472// Merge order: cache (locally-known fields not yet flushed to disk)473// → disk existing (cross-process writes win over stale cache, Step 3b)474// → explicit `metadata` fields from this call (caller wins).475// `undefined` values in `metadata` delete the corresponding key.476const cacheExisting = diskFileExisted ? {} : (this._cache[sessionId] ?? {});477const merged: ChatSessionMetadataFile = { ...cacheExisting, ...existing };478for (const [key, value] of Object.entries(updates)) {479if (value === undefined) {480delete (merged as Record<string, unknown>)[key];481} else {482(merged as Record<string, unknown>)[key] = value;483}484}485486// Stamp timestamps. `created` is set only on first write; `modified` is487// bumped on every write.488const now = Date.now();489merged.modified = now;490if (merged.created === undefined) {491merged.created = now;492}493494const content = new TextEncoder().encode(JSON.stringify(merged, null, 2));495await this.fileSystemService.writeFile(fileUri, content);496497this._cache[sessionId] = { ...merged, writtenToDisc: true };498this._updateFolderIndex(sessionId, this._cache[sessionId]);499this.updateGlobalStorage();500this.logService.trace(`[ChatSessionMetadataStore] Wrote metadata for session ${sessionId}`);501});502}503504private async getGlobalStorageData() {505const data = await this.fileSystemService.readFile(this._cacheFile);506return JSON.parse(new TextDecoder().decode(data)) as Record<string, ChatSessionMetadataFile>;507}508509private updateGlobalStorage() {510this._updateStorageDebouncer.trigger(() => this.updateGlobalStorageImpl()).catch(() => { /* expected on dispose */ });511}512513private async updateGlobalStorageImpl() {514try {515// Serialize against `refresh()` and other bulk-file flushes via the shared516// single-key sequencer. Inside the queue we re-read the on-disk file and517// merge it with the in-memory cache using last-modified-wins semantics so518// concurrent writers in another process do not lose data.519await this._bulkSequencer.queue(BULK_SEQUENCER_KEY, async () => {520const data: Record<string, ChatSessionMetadataFile> = { ...this._cache };521try {522const storageData = await this.getGlobalStorageData();523for (const [sessionId, diskEntry] of Object.entries(storageData)) {524const local = data[sessionId];525if (!local) {526data[sessionId] = diskEntry;527this._cache[sessionId] = diskEntry;528this._updateFolderIndex(sessionId, diskEntry);529continue;530}531const localModified = local.modified ?? 0;532const diskModified = diskEntry.modified ?? 0;533if (diskModified > localModified) {534data[sessionId] = diskEntry;535this._cache[sessionId] = diskEntry;536this._updateFolderIndex(sessionId, diskEntry);537}538}539} catch {540//541}542await this.writeToGlobalStorage(data);543});544} catch (error) {545this.logService.error('[ChatSessionMetadataStore] Failed to update global storage: ', error);546}547}548549private async writeToGlobalStorage(allMetadata: Record<string, ChatSessionMetadataFile>): Promise<void> {550// Make a shallow copy and trim to the top MAX_BULK_STORAGE_ENTRIES by `modified` desc.551// The in-memory `_cache` is unaffected — only the on-disk file is bounded.552// Per-session files in `~/.copilot/session-state/{id}/vscode.metadata.json` remain553// the source of truth for evicted entries.554const entries = Object.entries(allMetadata);555let toWrite: Record<string, ChatSessionMetadataFile>;556if (entries.length <= MAX_BULK_STORAGE_ENTRIES) {557toWrite = { ...allMetadata };558} else {559entries.sort(([, a], [, b]) => (b.modified ?? 0) - (a.modified ?? 0));560toWrite = Object.fromEntries(entries.slice(0, MAX_BULK_STORAGE_ENTRIES));561}562563const dirUri = dirname(this._cacheFile);564try {565await this.fileSystemService.stat(dirUri);566} catch {567await this.fileSystemService.createDirectory(dirUri);568}569570const content = new TextEncoder().encode(JSON.stringify(toWrite, null, 2));571await this.fileSystemService.writeFile(this._cacheFile, content);572this.logService.trace(`[ChatSessionMetadataStore] Wrote bulk metadata file with ${Object.keys(toWrite).length} session(s)`);573}574575/**576* Re-reads the shared bulk file from disk and merges into `_cache` using577* last-modified-wins. Runs inside the bulk sequencer so it is serialized578* against {@link updateGlobalStorageImpl}. Never drops in-memory entries.579*/580private async reloadBulkFromDisk(): Promise<void> {581return this._bulkSequencer.queue(BULK_SEQUENCER_KEY, async () => {582let onDisk: Record<string, ChatSessionMetadataFile>;583try {584onDisk = await this.getGlobalStorageData();585} catch {586return;587}588for (const [id, diskEntry] of Object.entries(onDisk)) {589const local = this._cache[id];590if (!local) {591this._cache[id] = diskEntry;592this._updateFolderIndex(id, diskEntry);593continue;594}595const localModified = local.modified ?? 0;596const diskModified = diskEntry.modified ?? 0;597if (diskModified > localModified) {598this._cache[id] = diskEntry;599this._updateFolderIndex(id, diskEntry);600}601}602});603}604605/**606* Merges the per-install legacy bulk file (`globalStorageUri/copilotcli/…`) into607* the shared `~/.copilot/` bulk file using last-modified-wins. This handles:608* - First-run: shared file missing → copy legacy content into the shared file.609* - Late-joiner: Process A already created the shared file → merge so entries610* unique to this install are not lost.611* - No legacy file: nothing to do.612*/613private async migrateLegacyBulkFile(): Promise<void> {614// Skip if this install already migrated.615if (this.extensionContext.globalState.get<boolean>(LEGACY_BULK_MIGRATED_KEY)) {616return;617}618619const legacyCacheFile = Uri.joinPath(this.extensionContext.globalStorageUri, 'copilotcli', LEGACY_BULK_METADATA_FILENAME);620let legacyData: Record<string, ChatSessionMetadataFile>;621try {622const raw = await this.fileSystemService.readFile(legacyCacheFile);623legacyData = JSON.parse(new TextDecoder().decode(raw));624} catch {625// No legacy file — mark as migrated so we don't retry.626await this.extensionContext.globalState.update(LEGACY_BULK_MIGRATED_KEY, true);627return;628}629630try {631await createDirectoryIfNotExists(this.fileSystemService, dirname(this._cacheFile));632633// Try to read the shared file (may or may not exist yet).634let sharedData: Record<string, ChatSessionMetadataFile> = {};635try {636const raw = await this.fileSystemService.readFile(this._cacheFile);637sharedData = JSON.parse(new TextDecoder().decode(raw));638} catch {639// Shared file doesn't exist yet — start empty.640}641642// Merge legacy into shared using last-modified-wins.643let merged = false;644for (const [id, legacyEntry] of Object.entries(legacyData)) {645const sharedEntry = sharedData[id];646if (!sharedEntry) {647sharedData[id] = legacyEntry;648merged = true;649} else {650const sharedModified = sharedEntry.modified ?? 0;651const legacyModified = legacyEntry.modified ?? 0;652if (legacyModified > sharedModified) {653sharedData[id] = legacyEntry;654merged = true;655}656}657}658659if (merged) {660const content = new TextEncoder().encode(JSON.stringify(sharedData, null, 2));661await this.fileSystemService.writeFile(this._cacheFile, content);662}663664// Mark as migrated so subsequent startups skip this path.665await this.extensionContext.globalState.update(LEGACY_BULK_MIGRATED_KEY, true);666this.logService.info('[ChatSessionMetadataStore] Migrated legacy bulk metadata file to ~/.copilot/');667} catch (err) {668this.logService.error('[ChatSessionMetadataStore] Failed to migrate legacy bulk file: ', err);669}670}671}672673674