Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion backend/application/BackendApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -198,8 +198,11 @@ export class BackendApplication {
private readonly conversationEvictionGeneration = new Map<string, number>();
private conversationEvictionInFlight: string | undefined;
private readonly conversationHistoryChangedEmitter = new vscode.EventEmitter<void>();
private readonly storageRootChangedEmitter = new vscode.EventEmitter<void>();
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);
Expand Down Expand Up @@ -615,6 +618,11 @@ export class BackendApplication {
return this.env.storage.paths.conversationHistoryRootUri;
}

/** 重新读盘并把某个全局设置 section 的最新值广播给所有订阅者。幂等纯读操作。 */
public postGlobalSettingsSnapshot(section: GlobalSettingsSection): Promise<void> {
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);
Expand Down Expand Up @@ -1411,6 +1419,7 @@ export class BackendApplication {
await this.syncSkillCatalogResource();
// 同理,全局规则来源 <dataRoot>/{AGENTS,CLAUDE}.md 也随数据根变化,需重新扫描规则。
await this.syncRulesCatalogResource();
this.storageRootChangedEmitter.fire();
}
}

Expand Down
15 changes: 13 additions & 2 deletions backend/application/GlobalSettingsBridge.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { StorageCapability, WebviewCapability } from '../capabilities/types';
import { isSettingsRevisionConflictError } from '../capabilities/settingsRevisionConflict';
import {
BridgeMessageType,
GLOBAL_SETTINGS_SECTIONS,
Expand Down Expand Up @@ -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),
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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 } : {})
}
};
}
Expand Down
29 changes: 29 additions & 0 deletions backend/capabilities/settingsRevisionConflict.ts
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 11 additions & 2 deletions backend/capabilities/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -687,8 +695,9 @@ export interface StorageCapability {
collectShadowWorktreeStats(): Promise<ShadowRepositoryDiskStatRecord[]>;
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<GlobalSettingsStoreResult>;
/** expectedRevision 可选:传入时做乐观并发校验,不传完全保持旧行为。 */
saveGlobalSettings(section: GlobalSettingsSection, settings: GlobalSettingsSectionValue, expectedRevision?: string): Promise<GlobalSettingsStoreResult>;
loadActiveLlmProviderConfig(conversationId?: string): Promise<LlmProviderConfigRecord>;
loadLlmProviderConfigById(configId: string): Promise<LlmProviderConfigRecord | undefined>;
loadActiveLlmCompressionConfig(providerConfigId?: string, modelId?: string): Promise<LlmCompressionConfigRecord | undefined>;
Expand Down
53 changes: 44 additions & 9 deletions backend/capabilities/vscodeStorage/globalSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,6 +31,14 @@ interface GlobalSettingsFile<T> {
settings: T;
}

export interface GlobalSettingsFileResult {
section: GlobalSettingsSection;
settings: GlobalSettingsSectionValue;
filePath: string;
/** 当前落盘版本:直接复用文件里已有的 savedAt,零新增字段、零 schema 变更。 */
revision?: string;
}

type FileBackedGlobalSettingsSection = Exclude<GlobalSettingsSection, 'common' | 'llmProviderConfigs' | 'llmCompressionConfigs' | 'mcpServers'>;

const GLOBAL_SETTINGS_SECTION_SPECS: Record<FileBackedGlobalSettingsSection, {
Expand Down Expand Up @@ -156,7 +165,7 @@ export async function ensureGlobalSettingsFile(root: vscode.Uri, section: Global
export async function loadGlobalSettingsFile(
root: vscode.Uri,
section: GlobalSettingsSection
): Promise<{ section: GlobalSettingsSection; settings: GlobalSettingsSectionValue; filePath: string }> {
): Promise<GlobalSettingsFileResult> {
const uri = globalSettingsFileUri(root, section);
const result = await readJsonStrict<unknown>(uri);
if (result.status === 'missing') return initializeMissingGlobalSettingsFile(root, section);
Expand All @@ -165,32 +174,55 @@ 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<void> {
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<string | undefined> {
const current = await readJsonStrict<unknown>(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);
}

async function initializeMissingGlobalSettingsFile(
root: vscode.Uri,
section: GlobalSettingsSection
): Promise<{ section: GlobalSettingsSection; settings: GlobalSettingsSectionValue; filePath: string }> {
): Promise<GlobalSettingsFileResult> {
const uri = globalSettingsFileUri(root, section);
return withStorageResourceLock(uri, async () => {
const current = await readJsonStrict<unknown>(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);
Expand All @@ -201,28 +233,31 @@ 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<GlobalSettingsSectionValue> | undefined),
filePath: uri.fsPath
filePath: uri.fsPath,
revision: file.savedAt
};
}

async function writeGlobalSettingsFileUnlocked(
uri: vscode.Uri,
section: GlobalSettingsSection,
settings: GlobalSettingsSectionValue
): Promise<void> {
): Promise<string> {
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<GlobalSettingsSectionValue> | undefined)
} satisfies GlobalSettingsFile<GlobalSettingsSectionValue>);
return savedAt;
}

function parseGlobalSettingsFile(
Expand Down
32 changes: 16 additions & 16 deletions backend/capabilities/vscodeStorage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LlmCompressionSettingsRecord> | 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<LlmProviderConfigsRecord> | undefined);
const stored = await saveLlmProviderConfigsSettings(paths, settings as Partial<LlmProviderConfigsRecord> | 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<LlmCompressionSettingsRecord> | 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<LlmCompressionConfigsRecord> | undefined);
return { section, settings: stored.settings, filePath: stored.filePath };
const stored = await saveLlmCompressionConfigsSettings(paths, settings as Partial<LlmCompressionConfigsRecord> | undefined, expectedRevision);
return { section, settings: stored.settings, filePath: stored.filePath, revision: stored.revision };
}
if (section === 'mcpServers') {
const stored = await saveMcpServersSettings(paths, settings as Partial<McpServersSettingsRecord> | undefined);
return { section, settings: stored.settings, filePath: stored.filePath };
const stored = await saveMcpServersSettings(paths, settings as Partial<McpServersSettingsRecord> | 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);
});
},
Expand Down Expand Up @@ -552,14 +552,14 @@ async function ensureLlmSettingsRoots(paths: StoragePaths): Promise<void> {
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<ConversationSettingsRecord> | undefined): ConversationSettingsRecord {
Expand Down
Loading
Loading