diff --git a/backend/application/BackendApplication.ts b/backend/application/BackendApplication.ts index f45b880e..ce5abfbc 100644 --- a/backend/application/BackendApplication.ts +++ b/backend/application/BackendApplication.ts @@ -90,7 +90,7 @@ import { CLIENT_STATE_TABLE_KEYS } from '../../shared/clientStateSchema'; import { EffectHandlerRegistry, registerApplicationEffectHandlers } from './effectHandlers'; import { flushEffects, flushEffectsWhere } from './executeEffects'; import type { RuntimeEnv } from './RuntimeEnv'; -import { BridgeMessageType, GLOBAL_SETTINGS_SECTIONS, conversationClientStateStreamId, createMessageId } from '../../shared/protocol'; +import { BridgeMessageType, GLOBAL_SETTINGS_SECTIONS, conversationClientStateStreamId, createMessageId, type GlobalSettingsSection } from '../../shared/protocol'; import type { AgentRunStatus, CheckpointMaintenanceSettingsRecord, @@ -198,8 +198,11 @@ export class BackendApplication { private readonly conversationEvictionGeneration = new Map(); private conversationEvictionInFlight: string | undefined; private readonly conversationHistoryChangedEmitter = new vscode.EventEmitter(); + private readonly storageRootChangedEmitter = new vscode.EventEmitter(); private readonly disposables: vscode.Disposable[] = []; public readonly onDidChangeConversationHistory = this.conversationHistoryChangedEmitter.event; + /** data root 被切换后触发,供文件监听器重建到新的根目录。 */ + public readonly onDidChangeStorageRoot = this.storageRootChangedEmitter.event; public constructor(context: vscode.ExtensionContext) { const { env, toolSchemas, toolDefinitions } = createRuntimeEnv(context); @@ -615,6 +618,11 @@ export class BackendApplication { return this.env.storage.paths.conversationHistoryRootUri; } + /** 重新读盘并把某个全局设置 section 的最新值广播给所有订阅者。幂等纯读操作。 */ + public postGlobalSettingsSnapshot(section: GlobalSettingsSection): Promise { + return this.globalSettingsBridge.postSnapshot(undefined, section); + } + public attachWebview(webview: vscode.Webview, meta: WebviewClientMeta = { kind: 'unknown' }): BridgeClientId { const clientId = this.env.webview.attach(webview, meta); this.webviewClients.register(clientId, meta); @@ -1411,6 +1419,7 @@ export class BackendApplication { await this.syncSkillCatalogResource(); // 同理,全局规则来源 /{AGENTS,CLAUDE}.md 也随数据根变化,需重新扫描规则。 await this.syncRulesCatalogResource(); + this.storageRootChangedEmitter.fire(); } } diff --git a/backend/application/GlobalSettingsBridge.ts b/backend/application/GlobalSettingsBridge.ts index af22ea1d..fa14b881 100644 --- a/backend/application/GlobalSettingsBridge.ts +++ b/backend/application/GlobalSettingsBridge.ts @@ -1,4 +1,5 @@ import type { StorageCapability, WebviewCapability } from '../capabilities/types'; +import { isSettingsRevisionConflictError } from '../capabilities/settingsRevisionConflict'; import { BridgeMessageType, GLOBAL_SETTINGS_SECTIONS, @@ -52,7 +53,7 @@ export class GlobalSettingsBridge { } await this.deps.beforeUpdate?.(payload); - const stored = await this.deps.storage.saveGlobalSettings(payload.section, payload.settings); + const stored = await this.deps.storage.saveGlobalSettings(payload.section, payload.settings, payload.expectedRevision); await this.deps.afterUpdate?.(payload); this.deps.webview.broadcastToStream( globalSettingsStreamId(payload.section), @@ -70,6 +71,15 @@ export class GlobalSettingsBridge { } } catch (error) { console.warn('[LimCode] Failed to update global settings:', error); + // 写入被乐观并发拒绝:盘上数据未被动过。先把最新值推回前端(避免面板停留在陈旧快照), + // 再发错误信封;顺序不能颠倒,否则 snapshot 会清掉刚设置的错误提示。 + if (isSettingsRevisionConflictError(error)) { + try { + await this.postSnapshot(undefined, payload.section); + } catch (snapshotError) { + console.warn('[LimCode] Failed to refresh global settings after revision conflict:', snapshotError); + } + } this.postSettingsError(BridgeMessageType.GlobalSettingsUpdate, payload.section, error, correlationId); } } @@ -110,7 +120,8 @@ export class GlobalSettingsBridge { payload: { section: stored.section, settings: stored.settings, - filePath: stored.filePath + filePath: stored.filePath, + ...(stored.revision !== undefined ? { revision: stored.revision } : {}) } }; } diff --git a/backend/capabilities/settingsRevisionConflict.ts b/backend/capabilities/settingsRevisionConflict.ts new file mode 100644 index 00000000..be80ed21 --- /dev/null +++ b/backend/capabilities/settingsRevisionConflict.ts @@ -0,0 +1,29 @@ +/** + * 设置写入的乐观并发冲突(optimistic concurrency conflict)。 + * + * 多窗口同时打开设置面板时,B 窗口手里的列表可能早于 A 窗口刚保存的结果。 + * 存储层的全量保存会把「B 的旧列表」当成权威,从而静默删掉 A 新增的记录—— + * 这类丢失靠文件锁是防不住的,只能靠写入前的版本比对拒绝过期提交。 + */ +export const SETTINGS_REVISION_CONFLICT_NAME = 'SettingsRevisionConflictError'; + +export class SettingsRevisionConflictError extends Error { + /** 跨模块实例(bundle 拆分/重复加载)也能安全识别的标记。 */ + public readonly settingsRevisionConflict = true; + + public constructor( + public readonly section: string, + public readonly expectedRevision?: string, + public readonly actualRevision?: string + ) { + super(`设置「${section}」已在其他窗口修改,本次保存已被拒绝以避免覆盖。已为你载入最新值,请重新修改。`); + this.name = SETTINGS_REVISION_CONFLICT_NAME; + } +} + +export function isSettingsRevisionConflictError(error: unknown): error is SettingsRevisionConflictError { + if (error instanceof SettingsRevisionConflictError) return true; + if (!error || typeof error !== 'object') return false; + const candidate = error as { settingsRevisionConflict?: unknown; name?: unknown }; + return candidate.settingsRevisionConflict === true || candidate.name === SETTINGS_REVISION_CONFLICT_NAME; +} diff --git a/backend/capabilities/types.ts b/backend/capabilities/types.ts index 58b26827..ffecc988 100644 --- a/backend/capabilities/types.ts +++ b/backend/capabilities/types.ts @@ -631,6 +631,14 @@ export interface ResolvedAttachmentForClientResult { error?: string; } +export interface GlobalSettingsStoreResult { + section: GlobalSettingsSection; + settings: GlobalSettingsSectionValue; + filePath: string; + /** 当前落盘版本(复用存储层已有的 savedAt),用于下一次写入的乐观并发校验。 */ + revision?: string; +} + export interface StorageCapability { /** 当前 active data root 派生出的路径;数据目录切换后 getter 会返回新路径。 */ readonly paths: RuntimePaths; @@ -687,8 +695,9 @@ export interface StorageCapability { collectShadowWorktreeStats(): Promise; deleteShadowWorktrees(storageKeys: string[]): Promise<{ deletedStorageKeys: string[] }>; cleanupUnusedShadowWorktrees(maxAgeDays: number): Promise<{ deletedStorageKeys: string[] }>; - loadGlobalSettings(section: GlobalSettingsSection): Promise<{ section: GlobalSettingsSection; settings: GlobalSettingsSectionValue; filePath: string }>; - saveGlobalSettings(section: GlobalSettingsSection, settings: GlobalSettingsSectionValue): Promise<{ section: GlobalSettingsSection; settings: GlobalSettingsSectionValue; filePath: string }>; + loadGlobalSettings(section: GlobalSettingsSection): Promise; + /** expectedRevision 可选:传入时做乐观并发校验,不传完全保持旧行为。 */ + saveGlobalSettings(section: GlobalSettingsSection, settings: GlobalSettingsSectionValue, expectedRevision?: string): Promise; loadActiveLlmProviderConfig(conversationId?: string): Promise; loadLlmProviderConfigById(configId: string): Promise; loadActiveLlmCompressionConfig(providerConfigId?: string, modelId?: string): Promise; diff --git a/backend/capabilities/vscodeStorage/globalSettings.ts b/backend/capabilities/vscodeStorage/globalSettings.ts index 7d379af8..f0621dc9 100644 --- a/backend/capabilities/vscodeStorage/globalSettings.ts +++ b/backend/capabilities/vscodeStorage/globalSettings.ts @@ -19,6 +19,7 @@ import { RUN_HISTORY_SETTINGS_FILE, STORAGE_VERSION } from './constants'; +import { SettingsRevisionConflictError } from '../settingsRevisionConflict'; import { readJsonStrict, writeJson, type StrictJsonReadResult } from './json'; import { createDefaultLlmSettings, normalizeLlmSettings } from './llmSettings'; import { normalizeLlmCompressionSettings } from './llmCompressionConfigs'; @@ -30,6 +31,14 @@ interface GlobalSettingsFile { settings: T; } +export interface GlobalSettingsFileResult { + section: GlobalSettingsSection; + settings: GlobalSettingsSectionValue; + filePath: string; + /** 当前落盘版本:直接复用文件里已有的 savedAt,零新增字段、零 schema 变更。 */ + revision?: string; +} + type FileBackedGlobalSettingsSection = Exclude; const GLOBAL_SETTINGS_SECTION_SPECS: Record { +): Promise { const uri = globalSettingsFileUri(root, section); const result = await readJsonStrict(uri); if (result.status === 'missing') return initializeMissingGlobalSettingsFile(root, section); @@ -165,17 +174,40 @@ export async function loadGlobalSettingsFile( return materializeGlobalSettingsFile(root, section, result.value); } +/** + * 写入全局设置文件。 + * 传入 expectedRevision 时,在已有的资源锁内再读一次盘上版本做 CAS; + * 不传则完全保持旧行为(内部初始化、迁移等路径不受影响)。 + */ export async function writeGlobalSettingsFile( root: vscode.Uri, section: GlobalSettingsSection, - settings: GlobalSettingsSectionValue + settings: GlobalSettingsSectionValue, + expectedRevision?: string ): Promise { const uri = globalSettingsFileUri(root, section); await withStorageResourceLock(uri, async () => { + if (expectedRevision !== undefined) { + const actualRevision = await readGlobalSettingsRevisionUnlocked(uri, section); + // 文件缺失/损坏时 revision 为 undefined:此时没有可被覆盖丢失的数据,不阻断写入。 + if (actualRevision !== undefined && actualRevision !== expectedRevision) { + throw new SettingsRevisionConflictError(section, expectedRevision, actualRevision); + } + } await writeGlobalSettingsFileUnlocked(uri, section, settings); }); } +async function readGlobalSettingsRevisionUnlocked(uri: vscode.Uri, section: GlobalSettingsSection): Promise { + const current = await readJsonStrict(uri); + if (current.status !== 'ok') return undefined; + try { + return parseGlobalSettingsFile(section, uri, current.value).savedAt; + } catch { + return undefined; + } +} + export function globalSettingsFileUri(root: vscode.Uri, section: GlobalSettingsSection): vscode.Uri { return vscode.Uri.joinPath(root, getFileBackedSpec(section).fileName); } @@ -183,14 +215,14 @@ export function globalSettingsFileUri(root: vscode.Uri, section: GlobalSettingsS async function initializeMissingGlobalSettingsFile( root: vscode.Uri, section: GlobalSettingsSection -): Promise<{ section: GlobalSettingsSection; settings: GlobalSettingsSectionValue; filePath: string }> { +): Promise { const uri = globalSettingsFileUri(root, section); return withStorageResourceLock(uri, async () => { const current = await readJsonStrict(uri); if (current.status === 'missing') { const defaults = getFileBackedSpec(section).createDefault(); - await writeGlobalSettingsFileUnlocked(uri, section, defaults); - return { section, settings: defaults, filePath: uri.fsPath }; + const revision = await writeGlobalSettingsFileUnlocked(uri, section, defaults); + return { section, settings: defaults, filePath: uri.fsPath, revision }; } if (current.status !== 'ok') throw strictJsonReadError('global settings', section, current); return materializeGlobalSettingsFile(root, section, current.value); @@ -201,14 +233,15 @@ function materializeGlobalSettingsFile( root: vscode.Uri, section: GlobalSettingsSection, value: unknown -): { section: GlobalSettingsSection; settings: GlobalSettingsSectionValue; filePath: string } { +): GlobalSettingsFileResult { const uri = globalSettingsFileUri(root, section); const spec = getFileBackedSpec(section); const file = parseGlobalSettingsFile(section, uri, value); return { section, settings: spec.normalize(file.settings as Partial | undefined), - filePath: uri.fsPath + filePath: uri.fsPath, + revision: file.savedAt }; } @@ -216,13 +249,15 @@ async function writeGlobalSettingsFileUnlocked( uri: vscode.Uri, section: GlobalSettingsSection, settings: GlobalSettingsSectionValue -): Promise { +): Promise { const spec = getFileBackedSpec(section); + const savedAt = new Date().toISOString(); await writeJson(uri, { schemaVersion: STORAGE_VERSION, - savedAt: new Date().toISOString(), + savedAt, settings: spec.normalize(settings as Partial | undefined) } satisfies GlobalSettingsFile); + return savedAt; } function parseGlobalSettingsFile( diff --git a/backend/capabilities/vscodeStorage/index.ts b/backend/capabilities/vscodeStorage/index.ts index 1917cebf..42c7483c 100644 --- a/backend/capabilities/vscodeStorage/index.ts +++ b/backend/capabilities/vscodeStorage/index.ts @@ -392,52 +392,52 @@ export function createVsCodeStorageCapability(context: vscode.ExtensionContext): if (section === 'llmProviderConfigs') { const stored = await loadLlmProviderConfigsSettings(paths); await loadNormalizedLlmGlobalSettings(paths); - return { section, settings: stored.settings, filePath: stored.filePath }; + return { section, settings: stored.settings, filePath: stored.filePath, revision: stored.revision }; } if (section === 'llmCompression') { const configs = (await loadLlmCompressionConfigsSettings(paths)).settings.configs; const stored = await loadGlobalSettingsFile(paths.settingsRootUri, 'llmCompression'); const settings = normalizeLlmCompressionSettings(stored.settings as Partial | undefined, configs); - return { section, settings, filePath: stored.filePath }; + return { section, settings, filePath: stored.filePath, revision: stored.revision }; } if (section === 'llmCompressionConfigs') { const stored = await loadLlmCompressionConfigsSettings(paths); - return { section, settings: stored.settings, filePath: stored.filePath }; + return { section, settings: stored.settings, filePath: stored.filePath, revision: stored.revision }; } if (section === 'mcpServers') { const stored = await loadMcpServersSettings(paths); - return { section, settings: stored.settings, filePath: stored.filePath }; + return { section, settings: stored.settings, filePath: stored.filePath, revision: stored.revision }; } await vscode.workspace.fs.createDirectory(paths.settingsRootUri); return loadGlobalSettingsFile(paths.settingsRootUri, section); }); }, - async saveGlobalSettings(section, settings) { + async saveGlobalSettings(section, settings, expectedRevision) { if (section === 'common') return saveCommonGlobalSettings(settings); return withSharedDataRoot(async (paths) => { if (section === 'llm') return saveNormalizedLlmGlobalSettings(paths, settings); if (section === 'llmProviderConfigs') { - const stored = await saveLlmProviderConfigsSettings(paths, settings as Partial | undefined); + const stored = await saveLlmProviderConfigsSettings(paths, settings as Partial | undefined, expectedRevision); await loadNormalizedLlmGlobalSettings(paths); - return { section, settings: stored.settings, filePath: stored.filePath }; + return { section, settings: stored.settings, filePath: stored.filePath, revision: stored.revision }; } if (section === 'llmCompression') { const configs = (await loadLlmCompressionConfigsSettings(paths)).settings.configs; const normalized = normalizeLlmCompressionSettings(settings as Partial | undefined, configs); - await writeGlobalSettingsFile(paths.settingsRootUri, 'llmCompression', normalized); + await writeGlobalSettingsFile(paths.settingsRootUri, 'llmCompression', normalized, expectedRevision); const stored = await loadGlobalSettingsFile(paths.settingsRootUri, 'llmCompression'); - return { section, settings: stored.settings, filePath: stored.filePath }; + return { section, settings: stored.settings, filePath: stored.filePath, revision: stored.revision }; } if (section === 'llmCompressionConfigs') { - const stored = await saveLlmCompressionConfigsSettings(paths, settings as Partial | undefined); - return { section, settings: stored.settings, filePath: stored.filePath }; + const stored = await saveLlmCompressionConfigsSettings(paths, settings as Partial | undefined, expectedRevision); + return { section, settings: stored.settings, filePath: stored.filePath, revision: stored.revision }; } if (section === 'mcpServers') { - const stored = await saveMcpServersSettings(paths, settings as Partial | undefined); - return { section, settings: stored.settings, filePath: stored.filePath }; + const stored = await saveMcpServersSettings(paths, settings as Partial | undefined, expectedRevision); + return { section, settings: stored.settings, filePath: stored.filePath, revision: stored.revision }; } await vscode.workspace.fs.createDirectory(paths.settingsRootUri); - await writeGlobalSettingsFile(paths.settingsRootUri, section, settings); + await writeGlobalSettingsFile(paths.settingsRootUri, section, settings, expectedRevision); return loadGlobalSettingsFile(paths.settingsRootUri, section); }); }, @@ -552,14 +552,14 @@ async function ensureLlmSettingsRoots(paths: StoragePaths): Promise { await vscode.workspace.fs.createDirectory(paths.settingsRootUri); } -async function loadNormalizedLlmGlobalSettings(paths: StoragePaths): Promise<{ section: 'llm'; settings: LlmSettingsRecord; filePath: string }> { +async function loadNormalizedLlmGlobalSettings(paths: StoragePaths): Promise<{ section: 'llm'; settings: LlmSettingsRecord; filePath: string; revision?: string }> { await ensureLlmSettingsRoots(paths); const configs = (await loadLlmProviderConfigsSettings(paths)).settings.configs; const stored = await loadGlobalSettingsFile(paths.settingsRootUri, 'llm'); const settings = stored.settings as LlmSettingsRecord; const activeConfig = configs.find((config) => config.id === settings.activeProviderConfigId) ?? configs[0]; const normalized: LlmSettingsRecord = { activeProviderConfigId: activeConfig?.id ?? '' }; - return { section: 'llm', settings: normalized, filePath: stored.filePath }; + return { section: 'llm', settings: normalized, filePath: stored.filePath, revision: stored.revision }; } function normalizeConversationCommonSettings(conversationId: string, settings: Partial | undefined): ConversationSettingsRecord { diff --git a/backend/capabilities/vscodeStorage/llmCompressionConfigs.ts b/backend/capabilities/vscodeStorage/llmCompressionConfigs.ts index 062eb379..098e5f73 100644 --- a/backend/capabilities/vscodeStorage/llmCompressionConfigs.ts +++ b/backend/capabilities/vscodeStorage/llmCompressionConfigs.ts @@ -10,39 +10,51 @@ import type { import { DEFAULT_LLM_COMPRESSION_RESERVE_TOKENS, DEFAULT_LLM_COMPRESSION_TRIGGER_PERCENT, createDefaultLlmCompressionConfig } from '../../../shared/protocol'; import type { StoragePaths } from './clientStateStore'; import { INDEX_FILE } from './constants'; -import { loadRecordStore, removeRecordStoreRecord, saveRecordStore } from './recordStore'; +import { loadRecordStore, loadRecordStoreRevision, saveRecordStore, type SaveRecordStoreOptions } from './recordStore'; const RECORD_KEY = 'config'; const CONFIGS_DIR = 'llm-compression-configs'; const DEFAULT_CONFIG_NAME = '默认压缩方法'; +const REVISION_SECTION = 'llmCompressionConfigs'; -export async function loadLlmCompressionConfigsSettings(paths: StoragePaths): Promise<{ settings: LlmCompressionConfigsRecord; filePath: string }> { +export interface LlmCompressionConfigsSettingsResult { + settings: LlmCompressionConfigsRecord; + filePath: string; + revision?: string; +} + +export async function loadLlmCompressionConfigsSettings(paths: StoragePaths): Promise { const records = await loadRawLlmCompressionConfigRecords(paths); if (records.length > 0) { - return { settings: { configs: sortConfigs(records.map((record) => normalizeLlmCompressionConfig(record))) }, filePath: configsIndexUri(paths).fsPath }; + return { + settings: { configs: sortConfigs(records.map((record) => normalizeLlmCompressionConfig(record))) }, + filePath: configsIndexUri(paths).fsPath, + revision: await loadRecordStoreRevision(configsIndexUri(paths)) + }; } const config = normalizeLlmCompressionConfig(createDefaultLlmCompressionConfig(DEFAULT_CONFIG_NAME)); await writeLlmCompressionConfigRecords(paths, [config]); - return { settings: { configs: [config] }, filePath: configsIndexUri(paths).fsPath }; + return { + settings: { configs: [config] }, + filePath: configsIndexUri(paths).fsPath, + revision: await loadRecordStoreRevision(configsIndexUri(paths)) + }; } export async function saveLlmCompressionConfigsSettings( paths: StoragePaths, - settings: Partial | undefined -): Promise<{ settings: LlmCompressionConfigsRecord; filePath: string }> { - const previous = await loadLlmCompressionConfigsSettings(paths); + settings: Partial | undefined, + expectedRevision?: string +): Promise { const configs = normalizeConfigList(settings?.configs); if (configs.length === 0) throw new Error('至少需要保留一个压缩方法配置。'); - const nextIds = new Set(configs.map((config) => config.id)); - for (const previousConfig of previous.settings.configs) { - if (!nextIds.has(previousConfig.id)) { - await removeRecordStoreRecord(configsRootUri(paths), configsIndexUri(paths), previousConfig.id, RECORD_KEY); - } - } - - await writeLlmCompressionConfigRecords(paths, configs); + // 同 llmProviderConfigs:不在写入前逐条删除,而是靠同一把锁内的 pruneMissing 原子提交。 + await writeLlmCompressionConfigRecords(paths, configs, { + pruneMissing: true, + ...(expectedRevision !== undefined ? { expectedSavedAt: expectedRevision, expectedSavedAtSection: REVISION_SECTION } : {}) + }); return loadLlmCompressionConfigsSettings(paths); } @@ -115,8 +127,12 @@ async function loadRawLlmCompressionConfigRecords(paths: StoragePaths): Promise< return (await loadRecordStore(configsRootUri(paths), configsIndexUri(paths), RECORD_KEY)) ?? []; } -async function writeLlmCompressionConfigRecords(paths: StoragePaths, records: LlmCompressionConfigRecord[]): Promise { - await saveRecordStore(configsRootUri(paths), configsIndexUri(paths), sortConfigs(records.map(normalizeLlmCompressionConfig)), RECORD_KEY, (record) => record.name); +async function writeLlmCompressionConfigRecords( + paths: StoragePaths, + records: LlmCompressionConfigRecord[], + options: SaveRecordStoreOptions = {} +): Promise { + await saveRecordStore(configsRootUri(paths), configsIndexUri(paths), sortConfigs(records.map(normalizeLlmCompressionConfig)), RECORD_KEY, (record) => record.name, options); } function configsRootUri(paths: StoragePaths): vscode.Uri { diff --git a/backend/capabilities/vscodeStorage/llmProviderConfigs.ts b/backend/capabilities/vscodeStorage/llmProviderConfigs.ts index e89b6ad2..1031a8c6 100644 --- a/backend/capabilities/vscodeStorage/llmProviderConfigs.ts +++ b/backend/capabilities/vscodeStorage/llmProviderConfigs.ts @@ -29,41 +29,55 @@ import { import { DEFAULT_LLM_BASE_URL } from '../llmProvider'; import type { StoragePaths } from './clientStateStore'; import { INDEX_FILE } from './constants'; -import { loadRecordStore, removeRecordStoreRecord, saveRecordStore } from './recordStore'; +import { loadRecordStore, loadRecordStoreRevision, saveRecordStore, type SaveRecordStoreOptions } from './recordStore'; const RECORD_KEY = 'config'; const CONFIGS_DIR = 'llm-provider-configs'; const DEFAULT_CONFIG_NAME = '默认渠道'; +const REVISION_SECTION = 'llmProviderConfigs'; -export async function loadLlmProviderConfigsSettings(paths: StoragePaths): Promise<{ settings: LlmProviderConfigsRecord; filePath: string }> { +export interface LlmProviderConfigsSettingsResult { + settings: LlmProviderConfigsRecord; + filePath: string; + revision?: string; +} + +export async function loadLlmProviderConfigsSettings(paths: StoragePaths): Promise { const records = await loadRawLlmProviderConfigRecords(paths); if (records.length > 0) { - return { settings: { configs: sortConfigs(records.map((record) => normalizeLlmProviderConfig(record))) }, filePath: configsIndexUri(paths).fsPath }; + return { + settings: { configs: sortConfigs(records.map((record) => normalizeLlmProviderConfig(record))) }, + filePath: configsIndexUri(paths).fsPath, + revision: await loadRecordStoreRevision(configsIndexUri(paths)) + }; } const config = createDefaultLlmProviderConfig({ name: DEFAULT_CONFIG_NAME }); await writeLlmProviderConfigRecords(paths, [config]); - return { settings: { configs: [config] }, filePath: configsIndexUri(paths).fsPath }; + return { + settings: { configs: [config] }, + filePath: configsIndexUri(paths).fsPath, + revision: await loadRecordStoreRevision(configsIndexUri(paths)) + }; } export async function saveLlmProviderConfigsSettings( paths: StoragePaths, - settings: Partial | undefined -): Promise<{ settings: LlmProviderConfigsRecord; filePath: string }> { - const previous = await loadLlmProviderConfigsSettings(paths); + settings: Partial | undefined, + expectedRevision?: string +): Promise { const configs = normalizeConfigList(settings?.configs); if (configs.length === 0) { throw new Error('至少需要保留一个渠道配置。'); } - const nextIds = new Set(configs.map((config) => config.id)); - for (const previousConfig of previous.settings.configs) { - if (!nextIds.has(previousConfig.id)) { - await removeRecordStoreRecord(configsRootUri(paths), configsIndexUri(paths), previousConfig.id, RECORD_KEY); - } - } - - await writeLlmProviderConfigRecords(paths, configs); + // 删除已移除渠道的 record 文件不再单独走 removeRecordStoreRecord: + // 那会在写入前先做破坏性操作,冲突时数据已经没了。改为交给同一把锁内的 pruneMissing, + // 使「校验 → 发布新索引 → 清理旧文件」成为一次原子提交。 + await writeLlmProviderConfigRecords(paths, configs, { + pruneMissing: true, + ...(expectedRevision !== undefined ? { expectedSavedAt: expectedRevision, expectedSavedAtSection: REVISION_SECTION } : {}) + }); return loadLlmProviderConfigsSettings(paths); } @@ -147,13 +161,18 @@ async function loadRawLlmProviderConfigRecords(paths: StoragePaths): Promise { +async function writeLlmProviderConfigRecords( + paths: StoragePaths, + records: LlmProviderConfigRecord[], + options: SaveRecordStoreOptions = {} +): Promise { await saveRecordStore( configsRootUri(paths), configsIndexUri(paths), sortConfigs(records.map((record) => normalizeLlmProviderConfig(record))), RECORD_KEY, - (record) => record.name + (record) => record.name, + options ); } diff --git a/backend/capabilities/vscodeStorage/mcpServers.ts b/backend/capabilities/vscodeStorage/mcpServers.ts index 5a7aa627..c2d79f09 100644 --- a/backend/capabilities/vscodeStorage/mcpServers.ts +++ b/backend/capabilities/vscodeStorage/mcpServers.ts @@ -1,30 +1,41 @@ import * as vscode from 'vscode'; import type { McpServerConfigRecord, McpServersSettingsRecord, McpServerTransportRecord } from '../../../shared/protocol'; -import { loadRecordStore, saveRecordStore } from './recordStore'; +import { loadRecordStore, loadRecordStoreRevision, saveRecordStore } from './recordStore'; import type { createVscodeStoragePaths } from './paths'; type StoragePaths = ReturnType; const MCP_SERVERS_DIR = 'mcp-servers'; +const REVISION_SECTION = 'mcpServers'; -export async function loadMcpServersSettings(paths: StoragePaths): Promise<{ section: 'mcpServers'; settings: McpServersSettingsRecord; filePath: string }> { +export interface McpServersSettingsResult { + section: 'mcpServers'; + settings: McpServersSettingsRecord; + filePath: string; + revision?: string; +} + +export async function loadMcpServersSettings(paths: StoragePaths): Promise { const root = mcpServersRootUri(paths); const indexUri = mcpServersIndexUri(paths); const records = await loadRecordStore(root, indexUri, 'server'); const settings = normalizeMcpServersSettings({ servers: records ?? [] }); if (!records) await saveMcpServersSettings(paths, settings); - return { section: 'mcpServers', settings, filePath: indexUri.fsPath }; + return { section: 'mcpServers', settings, filePath: indexUri.fsPath, revision: await loadRecordStoreRevision(indexUri) }; } export async function saveMcpServersSettings( paths: StoragePaths, - input: Partial | undefined -): Promise<{ section: 'mcpServers'; settings: McpServersSettingsRecord; filePath: string }> { + input: Partial | undefined, + expectedRevision?: string +): Promise { const root = mcpServersRootUri(paths); const indexUri = mcpServersIndexUri(paths); const settings = normalizeMcpServersSettings(input); - await saveRecordStore(root, indexUri, settings.servers, 'server', (server) => server.name); - return { section: 'mcpServers', settings, filePath: indexUri.fsPath }; + await saveRecordStore(root, indexUri, settings.servers, 'server', (server) => server.name, { + ...(expectedRevision !== undefined ? { expectedSavedAt: expectedRevision, expectedSavedAtSection: REVISION_SECTION } : {}) + }); + return { section: 'mcpServers', settings, filePath: indexUri.fsPath, revision: await loadRecordStoreRevision(indexUri) }; } export function normalizeMcpServersSettings(input: Partial | undefined): McpServersSettingsRecord { diff --git a/backend/capabilities/vscodeStorage/recordStore.ts b/backend/capabilities/vscodeStorage/recordStore.ts index 7c2c0003..b8c1e81e 100644 --- a/backend/capabilities/vscodeStorage/recordStore.ts +++ b/backend/capabilities/vscodeStorage/recordStore.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { SettingsRevisionConflictError } from '../settingsRevisionConflict'; import { RECORDS_DIR, STORAGE_VERSION } from './constants'; import { isFileNotFoundError, readJson, readJsonStrict, writeJson } from './json'; import { sortableName } from './naming'; @@ -31,6 +32,13 @@ type RecordFile = { export interface SaveRecordStoreOptions { pruneMissing?: boolean; + /** + * 乐观并发校验:本次保存所基于的 index savedAt。 + * 不传 = 不校验 = 完全旧行为。校验直接搭在锁内本来就要做的 previousIndex 读取上,无额外 IO。 + */ + expectedSavedAt?: string; + /** 冲突报错中展示的 section 名;缺省用 index 路径。 */ + expectedSavedAtSection?: string; } export interface RecordStoreReadHookContext { @@ -156,6 +164,11 @@ export async function withRecordStoreTransaction(lockUri: vscode.Uri, action: return withRecordStoreMutationLock(lockUri, action); } +/** 读取 record store index 的当前 savedAt,用作乐观并发的 revision。index 缺失/损坏时返回 undefined。 */ +export async function loadRecordStoreRevision(indexUri: vscode.Uri): Promise { + return (await loadRecordsIndex(indexUri, false))?.savedAt; +} + export async function saveRecordStore( root: vscode.Uri, indexUri: vscode.Uri, @@ -181,6 +194,14 @@ async function saveRecordStoreUnlocked [record.id, record])); diff --git a/shared/protocol.ts b/shared/protocol.ts index 33493f20..c314d7b6 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -2591,11 +2591,15 @@ export interface GlobalSettingsSnapshotPayload { section: GlobalSettingsSection; settings: GlobalSettingsSectionValue; filePath: string; + /** 该 section 当前落盘版本(复用存储层已有的 savedAt)。回传给 update 用于乐观并发校验。 */ + revision?: string; } export interface GlobalSettingsUpdatePayload { section: GlobalSettingsSection; settings: GlobalSettingsSectionValue; refreshMcpTools?: boolean; + /** 本次修改所基于的落盘版本。不传表示不做校验(保持旧行为)。 */ + expectedRevision?: string; } export interface ConversationSettingsRecord { conversationId: string; diff --git a/tests/settingsRevisionConflict.test.cjs b/tests/settingsRevisionConflict.test.cjs new file mode 100644 index 00000000..d49a3de6 --- /dev/null +++ b/tests/settingsRevisionConflict.test.cjs @@ -0,0 +1,293 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const Module = require('node:module'); + +class MockUri { + constructor(fsPath) { + this.scheme = 'file'; + this.fsPath = path.resolve(fsPath); + this.path = this.fsPath.replace(/\\/g, '/'); + } + + static file(fsPath) { + return new MockUri(fsPath); + } + + static joinPath(base, ...segments) { + return new MockUri(path.join(base.fsPath, ...segments)); + } + + static from(input) { + return new MockUri(input.path || '/'); + } + + toString() { + return `file://${this.fsPath.replace(/\\/g, '/')}`; + } +} + +function installVscodeMock() { + const mock = { + Uri: MockUri, + FileType: { File: 1, Directory: 2 }, + workspace: { + workspaceFolders: [], + fs: { + createDirectory: (uri) => fs.mkdir(uri.fsPath, { recursive: true }), + readDirectory: async (uri) => (await fs.readdir(uri.fsPath, { withFileTypes: true })) + .map((entry) => [entry.name, entry.isDirectory() ? 2 : 1]), + delete: (uri) => fs.rm(uri.fsPath, { recursive: true, force: false }), + readFile: (uri) => fs.readFile(uri.fsPath), + writeFile: async (uri, data) => { + await fs.mkdir(path.dirname(uri.fsPath), { recursive: true }); + await fs.writeFile(uri.fsPath, data); + } + }, + registerTextDocumentContentProvider: () => ({ dispose() {} }) + }, + commands: { executeCommand: async () => undefined }, + window: { + showWarningMessage: async () => undefined, + showErrorMessage: async () => undefined, + showInformationMessage: async () => undefined + } + }; + const originalLoad = Module._load; + Module._load = function patchedLoad(request, parent, isMain) { + if (request === 'vscode') return mock; + return originalLoad.call(this, request, parent, isMain); + }; + return () => { Module._load = originalLoad; }; +} + +async function delay(milliseconds) { + await new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function removeTempRoot(target) { + for (let attempt = 1; ; attempt += 1) { + try { + await fs.rm(target, { recursive: true, force: true }); + return; + } catch (error) { + if (attempt >= 8 || !['EPERM', 'EACCES', 'EBUSY', 'ENOTEMPTY'].includes(error && error.code)) throw error; + await delay(25 * attempt); + } + } +} + +/** 递归读取目录下所有非锁文件的原始内容,用于断言“冲突时盘上数据逐字节不变”。 */ +async function readTree(root) { + const snapshot = new Map(); + async function walk(current, prefix) { + let entries; + try { + entries = await fs.readdir(current, { withFileTypes: true }); + } catch (error) { + if (error && error.code === 'ENOENT') return; + throw error; + } + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const absolute = path.join(current, entry.name); + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + await walk(absolute, relative); + continue; + } + if (relative.endsWith('.lock') || relative.endsWith('.tmp')) continue; + snapshot.set(relative, await fs.readFile(absolute, 'utf8')); + } + } + await walk(root, ''); + return snapshot; +} + +const restore = installVscodeMock(); +process.on('exit', restore); + +const { + loadLlmProviderConfigsSettings, + saveLlmProviderConfigsSettings, + createDefaultLlmProviderConfig +} = require('../dist/extension/backend/capabilities/vscodeStorage/llmProviderConfigs.js'); +const { + loadGlobalSettingsFile, + writeGlobalSettingsFile +} = require('../dist/extension/backend/capabilities/vscodeStorage/globalSettings.js'); +const { isSettingsRevisionConflictError } = require('../dist/extension/backend/capabilities/settingsRevisionConflict.js'); +const { GLOBAL_SETTINGS_WATCH_PATTERNS } = require('../dist/extension/vscode/watchers/GlobalSettingsWatcher.js'); + +function createPaths(tempRoot) { + return { settingsRootUri: MockUri.file(path.join(tempRoot, 'settings')) }; +} + +function configWithName(name) { + return { ...createDefaultLlmProviderConfig({ name }), name }; +} + +test('携带匹配 revision 的保存成功,并推进到新的 revision', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'limcode-revision-ok-')); + try { + const paths = createPaths(tempRoot); + const initial = await loadLlmProviderConfigsSettings(paths); + assert.equal(typeof initial.revision, 'string'); + assert.equal(initial.settings.configs.length, 1); + + await delay(5); + const nextConfigs = [...initial.settings.configs, configWithName('P1')]; + const saved = await saveLlmProviderConfigsSettings(paths, { configs: nextConfigs }, initial.revision); + + assert.equal(saved.settings.configs.length, 2); + assert.equal(typeof saved.revision, 'string'); + assert.notEqual(saved.revision, initial.revision); + } finally { + await removeTempRoot(tempRoot); + } +}); + +test('携带过期 revision 的保存被拒绝,且盘上数据逐字节不变', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'limcode-revision-stale-')); + try { + const paths = createPaths(tempRoot); + const configsRoot = path.join(tempRoot, 'settings', 'llm-provider-configs'); + const initial = await loadLlmProviderConfigsSettings(paths); + const staleRevision = initial.revision; + + await delay(5); + await saveLlmProviderConfigsSettings(paths, { configs: [...initial.settings.configs, configWithName('P1')] }, staleRevision); + + const before = await readTree(configsRoot); + await delay(5); + + // 陈旧快照:只有 P0,没有 P1。旧实现会把 P1 直接删掉。 + await assert.rejects( + () => saveLlmProviderConfigsSettings(paths, { configs: [...initial.settings.configs, configWithName('P2')] }, staleRevision), + (error) => { + assert.ok(isSettingsRevisionConflictError(error), `expected revision conflict, got: ${error}`); + assert.equal(error.section, 'llmProviderConfigs'); + return true; + } + ); + + const after = await readTree(configsRoot); + assert.deepEqual([...after.keys()].sort(), [...before.keys()].sort()); + for (const [file, content] of before) { + assert.equal(after.get(file), content, `冲突后文件被改动:${file}`); + } + } finally { + await removeTempRoot(tempRoot); + } +}); + +test('复现原 bug:B 窗口持旧 revision 提交时 A 窗口新增的渠道必须存活', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'limcode-revision-repro-')); + try { + const paths = createPaths(tempRoot); + // 两个窗口都读到同一份初始快照 [P0]。 + const windowA = await loadLlmProviderConfigsSettings(paths); + const windowB = await loadLlmProviderConfigsSettings(paths); + assert.equal(windowA.revision, windowB.revision); + + await delay(5); + // A 窗口先保存 [P0, P1]。 + await saveLlmProviderConfigsSettings(paths, { configs: [...windowA.settings.configs, configWithName('P1')] }, windowA.revision); + + await delay(5); + // B 窗口基于陈旧快照提交 [P0, P2]。 + await assert.rejects( + () => saveLlmProviderConfigsSettings(paths, { configs: [...windowB.settings.configs, configWithName('P2')] }, windowB.revision), + (error) => isSettingsRevisionConflictError(error) + ); + + const current = await loadLlmProviderConfigsSettings(paths); + const names = current.settings.configs.map((config) => config.name).sort(); + assert.deepEqual(names, ['P1', '默认渠道']); + } finally { + await removeTempRoot(tempRoot); + } +}); + +test('不传 expectedRevision 时完全保持旧行为(不校验、允许全量覆盖)', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'limcode-revision-legacy-')); + try { + const paths = createPaths(tempRoot); + const initial = await loadLlmProviderConfigsSettings(paths); + + await delay(5); + await saveLlmProviderConfigsSettings(paths, { configs: [...initial.settings.configs, configWithName('P1')] }); + + await delay(5); + // 旧行为:陈旧列表照样落盘,P1 被移除。这是本方案刻意保留的兼容路径。 + const saved = await saveLlmProviderConfigsSettings(paths, { configs: initial.settings.configs }); + assert.deepEqual(saved.settings.configs.map((config) => config.name), ['默认渠道']); + + const reloaded = await loadLlmProviderConfigsSettings(paths); + assert.deepEqual(reloaded.settings.configs.map((config) => config.name), ['默认渠道']); + } finally { + await removeTempRoot(tempRoot); + } +}); + +test('文件型 section 同样支持 revision 校验且冲突时不覆盖', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'limcode-revision-file-')); + try { + const settingsRoot = MockUri.file(path.join(tempRoot, 'settings')); + const initial = await loadGlobalSettingsFile(settingsRoot, 'attachments'); + assert.equal(typeof initial.revision, 'string'); + + await delay(5); + await writeGlobalSettingsFile(settingsRoot, 'attachments', { maxStoredInlineFileMb: 40 }, initial.revision); + const afterFirst = await loadGlobalSettingsFile(settingsRoot, 'attachments'); + assert.deepEqual(afterFirst.settings, { maxStoredInlineFileMb: 40 }); + assert.notEqual(afterFirst.revision, initial.revision); + + await delay(5); + await assert.rejects( + () => writeGlobalSettingsFile(settingsRoot, 'attachments', { maxStoredInlineFileMb: 99 }, initial.revision), + (error) => { + assert.ok(isSettingsRevisionConflictError(error), `expected revision conflict, got: ${error}`); + assert.equal(error.section, 'attachments'); + return true; + } + ); + + const afterConflict = await loadGlobalSettingsFile(settingsRoot, 'attachments'); + assert.deepEqual(afterConflict.settings, { maxStoredInlineFileMb: 40 }); + assert.equal(afterConflict.revision, afterFirst.revision); + + // 不传 expectedRevision 仍然直接覆盖。 + await delay(5); + await writeGlobalSettingsFile(settingsRoot, 'attachments', { maxStoredInlineFileMb: 99 }); + assert.deepEqual((await loadGlobalSettingsFile(settingsRoot, 'attachments')).settings, { maxStoredInlineFileMb: 99 }); + } finally { + await removeTempRoot(tempRoot); + } +}); + +test('settings watcher 白名单精确到具体文件,不会匹配高频的 conversation-*-llm.json', () => { + assert.ok(Array.isArray(GLOBAL_SETTINGS_WATCH_PATTERNS)); + assert.ok(GLOBAL_SETTINGS_WATCH_PATTERNS.length > 0); + + for (const pattern of GLOBAL_SETTINGS_WATCH_PATTERNS) { + assert.ok(pattern.startsWith('settings/'), `watch 模式必须限定在 settings/ 下:${pattern}`); + assert.equal(pattern.includes('conversation-'), false, `watch 模式不得覆盖会话级设置:${pattern}`); + // 顶层 settings 目录只允许花括号白名单,绝不允许 settings/*.json 这种通配。 + if (!pattern.includes('/', 'settings/'.length)) { + assert.ok(pattern.includes('{'), `顶层 settings 文件必须逐个白名单列举:${pattern}`); + assert.equal(pattern.includes('*'), false, `顶层 settings 文件不得使用通配:${pattern}`); + } + } + + const globalFiles = GLOBAL_SETTINGS_WATCH_PATTERNS.find((pattern) => pattern.includes('{')); + assert.ok(globalFiles, '缺少顶层设置文件白名单'); + for (const expected of ['llm', 'llm-compression', 'appearance', 'attachments', 'checkpoint-maintenance', 'run-history']) { + assert.ok(globalFiles.includes(expected), `白名单缺少 ${expected}.json`); + } + + assert.ok(GLOBAL_SETTINGS_WATCH_PATTERNS.some((pattern) => pattern.startsWith('settings/llm-provider-configs/'))); + assert.ok(GLOBAL_SETTINGS_WATCH_PATTERNS.some((pattern) => pattern.startsWith('settings/mcp-servers/'))); + assert.ok(GLOBAL_SETTINGS_WATCH_PATTERNS.some((pattern) => pattern.startsWith('settings/llm-compression-configs/'))); +}); diff --git a/vscode/extension.ts b/vscode/extension.ts index a444efe6..2d5938b2 100644 --- a/vscode/extension.ts +++ b/vscode/extension.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { registerCommands } from './commands/registerCommands'; import { MainPanel } from './panels/MainPanel'; import { registerSidebarEntryView } from './views/SidebarEntryView'; +import { registerGlobalSettingsWatcher } from './watchers/GlobalSettingsWatcher'; import { BackendApplication } from '../backend/application/BackendApplication'; let backendApp: BackendApplication | undefined; @@ -12,6 +13,7 @@ export function activate(context: vscode.ExtensionContext): void { MainPanel.registerSerializer(context, backendApp); registerCommands(context, backendApp); registerSidebarEntryView(context, backendApp); + registerGlobalSettingsWatcher(context, backendApp); console.log('LimCode (ECS backend) is active.'); } diff --git a/vscode/watchers/GlobalSettingsWatcher.ts b/vscode/watchers/GlobalSettingsWatcher.ts new file mode 100644 index 00000000..a5af3edd --- /dev/null +++ b/vscode/watchers/GlobalSettingsWatcher.ts @@ -0,0 +1,120 @@ +import * as vscode from 'vscode'; +import type { BackendApplication } from '../../backend/application/BackendApplication'; +import { GLOBAL_SETTINGS_SECTIONS, type GlobalSettingsSection } from '../../shared/protocol'; + +/** + * 全局设置文件监听白名单。 + * + * 必须逐个列举,绝不能写成 `settings/*.json`:settings 目录里绝大多数文件是 + * 每会话一份的 `conversation-*-llm.json`,多 agent 任务会在短时间内批量写入, + * 用通配会把刷新事件刷爆。 + */ +export const GLOBAL_SETTINGS_WATCH_PATTERNS = [ + 'settings/{llm,llm-compression,appearance,attachments,checkpoint-maintenance,run-history}.json', + 'settings/llm-provider-configs/**/*.json', + 'settings/llm-compression-configs/**/*.json', + 'settings/mcp-servers/**/*.json' +] as const; + +const REFRESH_DEBOUNCE_MS = 180; + +/** 顶层设置文件名 → section 映射,用于把文件事件收敛到需要刷新的 section。 */ +const FILE_NAME_SECTIONS: Record = { + 'llm.json': 'llm', + 'llm-compression.json': 'llmCompression', + 'appearance.json': 'appearance', + 'attachments.json': 'attachments', + 'checkpoint-maintenance.json': 'checkpointMaintenance', + 'run-history.json': 'runHistory' +}; + +/** 子目录 → section 映射。 */ +const DIRECTORY_SECTIONS: Record = { + 'llm-provider-configs': 'llmProviderConfigs', + 'llm-compression-configs': 'llmCompressionConfigs', + 'mcp-servers': 'mcpServers' +}; + +export function registerGlobalSettingsWatcher(context: vscode.ExtensionContext, backendApp: BackendApplication): void { + const watcher = new GlobalSettingsWatcher(backendApp); + context.subscriptions.push(watcher); + // data root 被用户改掉后,watcher 必须重建到新的根目录。 + context.subscriptions.push(backendApp.onDidChangeStorageRoot(() => watcher.ensureWatcher())); + watcher.ensureWatcher(); +} + +/** + * 监听 data root 下的全局设置文件,其他窗口写入后把最新值重新下发给本窗口。 + * + * 刷新动作就是 `postSnapshot`(重新读盘再广播),是幂等纯读操作, + * 所以不需要自写抑制:自己保存触发自己,只是把刚写的值再下发一次。 + */ +class GlobalSettingsWatcher implements vscode.Disposable { + private watchers: vscode.FileSystemWatcher[] = []; + private watchedRoot: string | undefined; + private refreshTimer: ReturnType | undefined; + private readonly dirtySections = new Set(); + + public constructor(private readonly backendApp: BackendApplication) {} + + public ensureWatcher(): void { + const root = this.backendApp.getStorageRootUri(); + const rootKey = root.toString(); + if (this.watchers.length > 0 && this.watchedRoot === rootKey) return; + + this.disposeWatchers(); + this.watchedRoot = rootKey; + + for (const pattern of GLOBAL_SETTINGS_WATCH_PATTERNS) { + const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(root, pattern)); + const schedule = (uri: vscode.Uri) => this.scheduleRefresh(uri); + watcher.onDidCreate(schedule); + watcher.onDidChange(schedule); + watcher.onDidDelete(schedule); + this.watchers.push(watcher); + } + } + + public dispose(): void { + if (this.refreshTimer !== undefined) clearTimeout(this.refreshTimer); + this.refreshTimer = undefined; + this.disposeWatchers(); + } + + private disposeWatchers(): void { + for (const watcher of this.watchers) watcher.dispose(); + this.watchers = []; + } + + private scheduleRefresh(uri: vscode.Uri): void { + const section = sectionFromSettingsUri(uri); + if (!section) return; + this.dirtySections.add(section); + + if (this.refreshTimer !== undefined) clearTimeout(this.refreshTimer); + this.refreshTimer = setTimeout(() => { + this.refreshTimer = undefined; + const sections = [...this.dirtySections]; + this.dirtySections.clear(); + for (const dirtySection of sections) { + void this.backendApp.postGlobalSettingsSnapshot(dirtySection) + .catch((error) => console.warn(`[LimCode] Failed to refresh global settings after external change: ${dirtySection}`, error)); + } + }, REFRESH_DEBOUNCE_MS); + } +} + +export function sectionFromSettingsUri(uri: vscode.Uri): GlobalSettingsSection | undefined { + const segments = uri.path.split('/').filter(Boolean); + const settingsIndex = segments.lastIndexOf('settings'); + if (settingsIndex < 0) return undefined; + + const rest = segments.slice(settingsIndex + 1); + if (rest.length === 0) return undefined; + if (rest.length === 1) return assertSection(FILE_NAME_SECTIONS[rest[0]]); + return assertSection(DIRECTORY_SECTIONS[rest[0]]); +} + +function assertSection(section: GlobalSettingsSection | undefined): GlobalSettingsSection | undefined { + return section && GLOBAL_SETTINGS_SECTIONS.includes(section) ? section : undefined; +} diff --git a/webview/src/stores/useGlobalSettingsStore.ts b/webview/src/stores/useGlobalSettingsStore.ts index 3bb1c3a7..594ae1f6 100644 --- a/webview/src/stores/useGlobalSettingsStore.ts +++ b/webview/src/stores/useGlobalSettingsStore.ts @@ -75,6 +75,12 @@ interface GlobalSettingsState { mcpServers: McpServersSettingsRecord; /** 各 section 的来源文件路径,用于在 UI 展示。 */ filePaths: Partial>; + /** 各 section 最近一次 snapshot 携带的落盘版本,保存时回传做乐观并发校验。 */ + revisions: Partial>; + /** 本窗口有未落盘修改时暂存的外部快照,等用户确认后再覆盖表单。 */ + pendingExternalSnapshots: Partial>; + /** 已检测到外部(其他窗口)修改、但尚未应用的 section。 */ + externalChangedSections: Partial>; /** 等待 llmProviderConfigs 保存完成后再持久化的 active provider id,避免 active id 先于新配置到达后端。 */ pendingActiveProviderConfigIdAfterConfigsSave: string; /** 克隆压缩配置后待 llmCompressionConfigs 保存确认再持久化压缩绑定,避免绑定先于新配置到达后端被丢弃。 */ @@ -96,10 +102,33 @@ interface GlobalSettingsErrorOptions { section?: GlobalSettingsSection; } +const GLOBAL_SETTINGS_SECTION_LABELS: Partial> = { + common: '基础设置', + llm: '当前渠道选择', + llmProviderConfigs: '渠道配置', + llmCompression: '压缩绑定', + llmCompressionConfigs: '压缩配置', + checkpointMaintenance: '存档点维护', + appearance: '外观', + attachments: '附件', + runHistory: '运行历史', + mcpServers: 'MCP 服务' +}; + function hasOutstandingSettingsWork(state: GlobalSettingsState): boolean { return Object.keys(state.loadingSettingsSections).length > 0 || Object.keys(state.pendingSettingsSections).length > 0; } +/** + * 该 section 在本窗口是否存在未落盘的修改。 + * 设置面板是自动保存的,所以「脏」= 有排队中的自动保存定时器,或有尚未被确认的保存请求。 + */ +function isSectionDirty(state: GlobalSettingsState, section: GlobalSettingsSection): boolean { + if (section === 'llmProviderConfigs' && hasPendingLlmProviderConfigsSave()) return true; + if (section === 'llmCompressionConfigs' && hasPendingLlmCompressionConfigsSave()) return true; + return state.pendingSettingsSections[section] === true; +} + function settingsErrorStatus(requestType: string | undefined, message: string): string { if (requestType === BridgeMessageType.GlobalSettingsGet) return `设置读取失败:${message}`; if (requestType === BridgeMessageType.LlmProviderModelsGet) return `获取模型列表失败:${message}`; @@ -761,6 +790,11 @@ const llmProviderConfigsSaveRequestRevisions = new Map(); let llmCompressionConfigsAutoSaveTimer: number | undefined; let llmCompressionConfigsEditRevision = 0; const llmCompressionConfigsSaveRequestRevisions = new Map(); +/** + * 每个 section 最后一次已同步的落盘版本(不会像 state.revisions 那样在发起保存时被消费掉)。 + * 用于区分「真外部变更」与「本窗口自己刚写的值被 watcher 再次下发」。 + */ +const lastSyncedRevisions = new Map(); function touchLlmProviderConfigsRevision(): number { llmProviderConfigsEditRevision += 1; @@ -817,6 +851,9 @@ export const useGlobalSettingsStore = defineStore('globalSettings', { runHistory: emptyRunHistory(), mcpServers: emptyMcpServers(), filePaths: {}, + revisions: {}, + pendingExternalSnapshots: {}, + externalChangedSections: {}, pendingActiveProviderConfigIdAfterConfigsSave: '', flushCompressionBindingAfterConfigsSave: false, loadedSections: {}, @@ -827,6 +864,15 @@ export const useGlobalSettingsStore = defineStore('globalSettings', { status: '' }), getters: { + /** 是否有外部窗口的修改等着被载入。 */ + hasExternalSettingsChange(state): boolean { + return Object.keys(state.externalChangedSections).length > 0; + }, + externalChangedSectionLabels(state): string { + return Object.keys(state.externalChangedSections) + .map((section) => GLOBAL_SETTINGS_SECTION_LABELS[section as GlobalSettingsSection] ?? section) + .join('\u3001'); + }, activeLlmProviderConfig(state): LlmProviderConfigRecord | undefined { return state.llmProviderConfigs.configs.find((config) => config.id === state.llm.activeProviderConfigId) ?? state.llmProviderConfigs.configs[0]; @@ -851,6 +897,17 @@ export const useGlobalSettingsStore = defineStore('globalSettings', { } }, actions: { + /** + * 取出并作废本地记录的落盘版本。 + * 请求一旦发出,本地 revision 即视为过期,直到收到新的 snapshot 才恢复校验; + * 否则同一窗口连发多次保存会反复携带同一个旧 revision,产生假冲突。 + */ + consumeExpectedRevision(section: GlobalSettingsSection): { expectedRevision?: string } { + const revision = this.revisions[section]; + if (revision === undefined) return {}; + delete this.revisions[section]; + return { expectedRevision: revision }; + }, markLoadingSettingSection(section: GlobalSettingsSection): void { this.loadingSettingsSections[section] = true; delete this.failedSettingsSections[section]; @@ -871,6 +928,10 @@ export const useGlobalSettingsStore = defineStore('globalSettings', { llmProviderConfigsSaveRequestRevisions.clear(); llmCompressionConfigsSaveRequestRevisions.clear(); this.status = '正在读取设置...'; + lastSyncedRevisions.clear(); + this.revisions = {}; + this.pendingExternalSnapshots = {}; + this.externalChangedSections = {}; this.loadedSections = {}; this.loadingSettingsSections = {}; this.pendingSettingsSections = {}; @@ -888,10 +949,12 @@ export const useGlobalSettingsStore = defineStore('globalSettings', { } }, saveCommon(): void { + const revision = this.consumeExpectedRevision('common'); this.markPendingSettingSection('common'); this.status = '正在保存设置,并按需迁移、删除旧数据目录中的插件数据...'; bridge.request(BridgeMessageType.GlobalSettingsUpdate, { section: 'common', + ...revision, settings: { dataFilePath: this.common.dataFilePath, proxy: this.common.proxy, @@ -901,10 +964,12 @@ export const useGlobalSettingsStore = defineStore('globalSettings', { }); }, saveLlm(): void { + const revision = this.consumeExpectedRevision('llm'); this.markPendingSettingSection('llm'); this.status = '正在保存当前渠道选择...'; bridge.request(BridgeMessageType.GlobalSettingsUpdate, { section: 'llm', + ...revision, settings: { activeProviderConfigId: this.llm.activeProviderConfigId } @@ -923,10 +988,12 @@ export const useGlobalSettingsStore = defineStore('globalSettings', { this.saveCheckpointMaintenance(); }, saveCheckpointMaintenance(): void { + const revision = this.consumeExpectedRevision('checkpointMaintenance'); this.markPendingSettingSection('checkpointMaintenance'); this.status = '正在保存存档点维护设置...'; bridge.request(BridgeMessageType.GlobalSettingsUpdate, { section: 'checkpointMaintenance', + ...revision, settings: { autoCleanupEnabled: this.checkpointMaintenance.autoCleanupEnabled, autoCleanupDays: this.checkpointMaintenance.autoCleanupDays, @@ -941,10 +1008,12 @@ export const useGlobalSettingsStore = defineStore('globalSettings', { bridge.request(BridgeMessageType.GlobalSettingsGet, { section: 'appearance' }); }, saveAppearance(): void { + const revision = this.consumeExpectedRevision('appearance'); this.markPendingSettingSection('appearance'); this.status = '正在保存外观设置...'; bridge.request(BridgeMessageType.GlobalSettingsUpdate, { section: 'appearance', + ...revision, settings: { streamingTextPreparing: this.appearance.streamingTextPreparing, streamingTextWaiting: this.appearance.streamingTextWaiting, @@ -966,10 +1035,12 @@ export const useGlobalSettingsStore = defineStore('globalSettings', { this.saveAttachments(); }, saveAttachments(): void { + const revision = this.consumeExpectedRevision('attachments'); this.markPendingSettingSection('attachments'); this.status = '正在保存附件设置...'; bridge.request(BridgeMessageType.GlobalSettingsUpdate, { section: 'attachments', + ...revision, settings: { maxStoredInlineFileMb: this.attachments.maxStoredInlineFileMb } @@ -986,10 +1057,12 @@ export const useGlobalSettingsStore = defineStore('globalSettings', { this.saveRunHistory(); }, saveRunHistory(): void { + const revision = this.consumeExpectedRevision('runHistory'); this.markPendingSettingSection('runHistory'); this.status = '正在保存运行历史设置...'; bridge.request(BridgeMessageType.GlobalSettingsUpdate, { section: 'runHistory', + ...revision, settings: { detailPersistenceEnabled: this.runHistory.detailPersistenceEnabled === true } @@ -1001,10 +1074,12 @@ export const useGlobalSettingsStore = defineStore('globalSettings', { bridge.request(BridgeMessageType.GlobalSettingsGet, { section: 'mcpServers' }); }, saveMcpServers(refreshMcpTools = false): void { + const revision = this.consumeExpectedRevision('mcpServers'); this.markPendingSettingSection('mcpServers'); this.status = refreshMcpTools ? '正在尝试获取 MCP 工具...' : '正在保存 MCP 服务...'; bridge.request(BridgeMessageType.GlobalSettingsUpdate, { section: 'mcpServers', + ...revision, settings: { servers: this.mcpServers.servers.map(toPlainMcpServer) }, @@ -1050,10 +1125,12 @@ export const useGlobalSettingsStore = defineStore('globalSettings', { saveLlmProviderConfigs(): void { clearLlmProviderConfigsAutoSaveTimer(); const requestRevision = touchLlmProviderConfigsRevision(); + const revision = this.consumeExpectedRevision('llmProviderConfigs'); this.markPendingSettingSection('llmProviderConfigs'); this.status = '正在自动保存渠道配置...'; const requestId = bridge.request(BridgeMessageType.GlobalSettingsUpdate, { section: 'llmProviderConfigs', + ...revision, settings: { configs: this.llmProviderConfigs.configs.map((config) => { const plain = toPlainProviderConfig(config); @@ -1084,7 +1161,11 @@ export const useGlobalSettingsStore = defineStore('globalSettings', { this.markPendingSettingSection('llmCompression'); this.status = '正在保存压缩绑定...'; try { - bridge.request(BridgeMessageType.GlobalSettingsUpdate, { section: 'llmCompression', settings: toPlainCompressionSettings(this.llmCompression) }); + bridge.request(BridgeMessageType.GlobalSettingsUpdate, { + section: 'llmCompression', + ...this.consumeExpectedRevision('llmCompression'), + settings: toPlainCompressionSettings(this.llmCompression) + }); } catch (error) { const message = `压缩绑定保存请求发送失败:${messageFromError(error)}`; this.clearPendingSettingSection('llmCompression'); @@ -1100,6 +1181,7 @@ export const useGlobalSettingsStore = defineStore('globalSettings', { try { const requestId = bridge.request(BridgeMessageType.GlobalSettingsUpdate, { section: 'llmCompressionConfigs', + ...this.consumeExpectedRevision('llmCompressionConfigs'), settings: { configs: this.llmCompressionConfigs.configs.map(toPlainCompressionConfig) } @@ -1655,7 +1737,39 @@ export const useGlobalSettingsStore = defineStore('globalSettings', { } this.saveLlmCompressionConfigs(); }, - applySnapshot(payload: GlobalSettingsSnapshotPayload, correlationId?: string): void { + /** 丢弃外部变更提示,保留本窗口当前表单内容。 */ + dismissExternalSettingsChange(section?: GlobalSettingsSection): void { + const sections = section ? [section] : (Object.keys(this.externalChangedSections) as GlobalSettingsSection[]); + for (const item of sections) { + delete this.externalChangedSections[item]; + delete this.pendingExternalSnapshots[item]; + } + }, + /** 用户确认后,把暂存的外部快照真正应用到表单。 */ + applyExternalSettingsChange(section?: GlobalSettingsSection): void { + const sections = section ? [section] : (Object.keys(this.pendingExternalSnapshots) as GlobalSettingsSection[]); + for (const item of sections) { + const payload = this.pendingExternalSnapshots[item]; + delete this.pendingExternalSnapshots[item]; + delete this.externalChangedSections[item]; + if (payload) this.applySnapshot(payload, undefined, { force: true }); + } + }, + applySnapshot(payload: GlobalSettingsSnapshotPayload, correlationId?: string, options: { force?: boolean } = {}): void { + // 外部(其他窗口)推送的快照不带 correlationId。 + // 本窗口还有没写完的修改时绝不能直接覆盖表单(例如用户正在输入 API key)。 + if (!options.force && correlationId === undefined && this.loadedSections[payload.section] && isSectionDirty(this, payload.section)) { + // 盘上版本就是本窗口上一次同步到的那个:什么都没变,不要报假的外部变更。 + if (payload.revision !== undefined && lastSyncedRevisions.get(payload.section) === payload.revision) return; + this.pendingExternalSnapshots[payload.section] = payload; + this.externalChangedSections[payload.section] = true; + return; + } + delete this.pendingExternalSnapshots[payload.section]; + delete this.externalChangedSections[payload.section]; + if (payload.revision !== undefined) lastSyncedRevisions.set(payload.section, payload.revision); + else lastSyncedRevisions.delete(payload.section); + const isLlmProviderConfigsSnapshot = payload.section === 'llmProviderConfigs'; const providerConfigsRequestRevision = isLlmProviderConfigsSnapshot && correlationId ? llmProviderConfigsSaveRequestRevisions.get(correlationId) @@ -1673,6 +1787,9 @@ export const useGlobalSettingsStore = defineStore('globalSettings', { this.loadedSections[payload.section] = true; this.filePaths[payload.section] = payload.filePath; + // 无论后续是否因本地编辑更新而跳过应用,盘上版本都要记下来供下一次保存校验。 + if (payload.revision !== undefined) this.revisions[payload.section] = payload.revision; + else delete this.revisions[payload.section]; this.clearLoadingSettingSection(payload.section); delete this.failedSettingsSections[payload.section]; diff --git a/webview/src/views/GlobalSettingsView.vue b/webview/src/views/GlobalSettingsView.vue index 57a2527c..f19bb3ba 100644 --- a/webview/src/views/GlobalSettingsView.vue +++ b/webview/src/views/GlobalSettingsView.vue @@ -1,5 +1,10 @@