char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Aleksander Grygierui: Replace per-conversation MCP overrides with per-conversation tool policy (#27745)fe235f434

char/aliceai-t5-moe
26.0 KiB878 linesraw
1/**
2 * MigrationService - Unified data migration hook
3 *
4 * Centralizes all data migrations (localStorage, IndexedDB, legacy formats)
5 * into a single initialization point. Each migration copies data to the new
6 * format WITHOUT deleting the old, and state is tracked in localStorage so
7 * `runAllMigrations()` (called once at startup) never re-runs a completed
8 * migration. All migrations are non-destructive for downgrade compatibility.
9 */
10
11import {
12	CONFIG_LOCALSTORAGE_KEY,
13	DB_APP_NAME_DEPRECATED,
14	DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
15	IDXDB_STORES,
16	IDXDB_TABLES,
17	LEGACY_AGENTIC_REGEX,
18	LEGACY_REASONING_TAGS,
19	NEW_TO_DEPRECATED_MAP,
20	SETTINGS_KEYS,
21	STORAGE_APP_NAME,
22	STORAGE_APP_NAME_DEPRECATED
23} from '$lib/constants';
24import { BooleanString, MessageRole } from '$lib/enums';
25import type { McpServerOverride } from '$lib/types/database';
26import Dexie from 'dexie';
27
28// Types
29
30interface Migration {
31	/** Unique identifier for this migration */
32	id: string;
33	/** Human-readable description */
34	description: string;
35	/** Run the migration forward (non-destructive - copies, doesn't delete) */
36	run(): Promise<void>;
37}
38
39interface MigrationState {
40	completed: string[];
41	failed: string[];
42	lastRun: string;
43}
44
45// Constants
46
47const MIGRATION_STATE_KEY = `${STORAGE_APP_NAME}.migration-state`;
48const MIGRATION_STATE_VERSION = 1;
49
50// State Management
51
52function getMigrationState(): MigrationState {
53	try {
54		const raw = localStorage.getItem(MIGRATION_STATE_KEY);
55
56		if (!raw) return { completed: [], failed: [], lastRun: '' };
57
58		const parsed = JSON.parse(raw);
59
60		if (parsed.version !== MIGRATION_STATE_VERSION) {
61			return { completed: [], failed: [], lastRun: '' };
62		}
63
64		return {
65			completed: parsed.completed ?? [],
66			failed: parsed.failed ?? [],
67			lastRun: parsed.lastRun ?? ''
68		};
69	} catch {
70		return { completed: [], failed: [], lastRun: '' };
71	}
72}
73
74function saveMigrationState(state: MigrationState): void {
75	localStorage.setItem(
76		MIGRATION_STATE_KEY,
77		JSON.stringify({
78			version: MIGRATION_STATE_VERSION,
79			...state,
80			lastRun: new Date().toISOString()
81		})
82	);
83}
84
85function isMigrationCompleted(id: string): boolean {
86	const state = getMigrationState();
87
88	return state.completed.includes(id);
89}
90
91function markMigrationCompleted(id: string): void {
92	const state = getMigrationState();
93
94	if (!state.completed.includes(id)) {
95		state.completed.push(id);
96	}
97
98	state.failed = state.failed.filter((f) => f !== id);
99	saveMigrationState(state);
100}
101
102function markMigrationFailed(id: string): void {
103	const state = getMigrationState();
104
105	if (!state.failed.includes(id)) {
106		state.failed.push(id);
107	}
108
109	saveMigrationState(state);
110}
111
112// Migration 1: LocalStorage Key Prefix (Non-Destructive)
113
114const LOCALSTORAGE_MIGRATION_ID = 'localstorage-prefix-v1';
115const localStorageMigration: Migration = {
116	description: 'Copy localStorage keys from LlamaCppWebui to LlamaUi prefix (non-destructive)',
117	id: LOCALSTORAGE_MIGRATION_ID,
118
119	async run(): Promise<void> {
120		// Non-destructive: copy to new key, but KEEP the old key
121		for (const [newKey, deprecatedKey] of Object.entries(NEW_TO_DEPRECATED_MAP)) {
122			// Only migrate if new key doesn't already exist
123			const newValue = localStorage.getItem(newKey);
124
125			if (newValue !== null) {
126				if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
127					console.log(`[Migration] localStorage: ${newKey} already exists, skipping`);
128
129				continue;
130			}
131
132			const oldValue = localStorage.getItem(deprecatedKey);
133
134			if (oldValue !== null) {
135				localStorage.setItem(newKey, oldValue);
136
137				// Keep old key for downgrade compatibility - DO NOT DELETE
138				if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
139					console.log(
140						`[Migration] localStorage: copied ${deprecatedKey} → ${newKey} (preserved old)`
141					);
142				}
143			}
144		}
145	}
146};
147
148// Migration 2: IndexedDB Database Name (Non-Destructive)
149
150// eslint-disable-next-line padding-line-between-statements -- comment header separates this const group
151const IDXDB_MIGRATION_ID = 'idxdb-database-v1';
152const idxdbMigration: Migration = {
153	description: 'Copy IndexedDB from LlamacppWebui to LlamaUi database (non-destructive)',
154	id: IDXDB_MIGRATION_ID,
155
156	async run(): Promise<void> {
157		const oldDbNames = await Dexie.getDatabaseNames();
158
159		if (!oldDbNames.includes(DB_APP_NAME_DEPRECATED)) {
160			if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
161				console.log('[Migration] IndexedDB: no old database found, skipping');
162
163			return;
164		}
165
166		// Check if new database already has data
167		const newDb = new Dexie(STORAGE_APP_NAME);
168
169		newDb.version(1).stores(IDXDB_STORES);
170		const existingConvs = await newDb.table(IDXDB_TABLES.conversations).count();
171
172		if (existingConvs > 0) {
173			if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
174				console.log('[Migration] IndexedDB: new database already has data, skipping');
175
176			return;
177		}
178
179		if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
180			console.log('[Migration] IndexedDB: copying from', DB_APP_NAME_DEPRECATED);
181
182		const oldDb = new Dexie(DB_APP_NAME_DEPRECATED);
183
184		oldDb.version(1).stores(IDXDB_STORES);
185
186		const conversations = await oldDb.table(IDXDB_TABLES.conversations).toArray();
187		const messages = await oldDb.table(IDXDB_TABLES.messages).toArray();
188
189		if (conversations.length > 0) {
190			await newDb.table(IDXDB_TABLES.conversations).bulkAdd(conversations);
191
192			if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
193				console.log(`[Migration] IndexedDB: copied ${conversations.length} conversations`);
194		}
195
196		if (messages.length > 0) {
197			await newDb.table(IDXDB_TABLES.messages).bulkAdd(messages);
198
199			if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
200				console.log(`[Migration] IndexedDB: copied ${messages.length} messages`);
201		}
202
203		// Non-destructive: DO NOT delete old database - keep for downgrade compatibility
204		if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
205			console.log('[Migration] IndexedDB: preserved old database for downgrade compatibility');
206	}
207};
208
209// Migration 3: Legacy Message Format
210
211// eslint-disable-next-line padding-line-between-statements -- comment header separates this const group
212const LEGACY_MESSAGE_MIGRATION_ID = 'legacy-message-format-v2';
213
214interface ParsedTurn {
215	textBefore: string;
216	toolCalls: Array<{ name: string; args: string; result: string }>;
217}
218
219function parseLegacyToolCalls(content: string): ParsedTurn[] {
220	const turns: ParsedTurn[] = [];
221	const regex = new RegExp(LEGACY_AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g');
222
223	let lastIndex = 0;
224	let currentTurn: ParsedTurn = { textBefore: '', toolCalls: [] };
225	let match;
226
227	while ((match = regex.exec(content)) !== null) {
228		const textBefore = content.slice(lastIndex, match.index).trim();
229
230		if (textBefore && currentTurn.toolCalls.length > 0) {
231			turns.push(currentTurn);
232			currentTurn = { textBefore, toolCalls: [] };
233		} else if (textBefore && currentTurn.toolCalls.length === 0) {
234			currentTurn.textBefore = textBefore;
235		}
236
237		currentTurn.toolCalls.push({
238			args: match[2],
239			name: match[1],
240			result: match[3].replace(/^\n+|\n+$/g, '')
241		});
242
243		lastIndex = match.index + match[0].length;
244	}
245
246	const remainingText = content.slice(lastIndex).trim();
247
248	if (currentTurn.toolCalls.length > 0) {
249		turns.push(currentTurn);
250	}
251
252	if (remainingText) {
253		const cleanRemaining = remainingText
254			.replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '')
255			.trim();
256
257		if (cleanRemaining) {
258			turns.push({ textBefore: cleanRemaining, toolCalls: [] });
259		}
260	}
261
262	if (turns.length === 0) {
263		turns.push({ textBefore: content.trim(), toolCalls: [] });
264	}
265
266	return turns;
267}
268
269function extractLegacyReasoning(content: string): { reasoning: string; cleanContent: string } {
270	let reasoning = '';
271	let cleanContent = content;
272
273	const re = new RegExp(LEGACY_AGENTIC_REGEX.REASONING_EXTRACT.source, 'g');
274
275	let match;
276
277	while ((match = re.exec(content)) !== null) {
278		reasoning += match[1];
279	}
280
281	cleanContent = cleanContent
282		.replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '')
283		.replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, '');
284
285	return { cleanContent, reasoning };
286}
287
288function hasLegacyMarkers(content: string): boolean {
289	return LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test(content);
290}
291
292let DatabaseService: typeof import('./database.service').DatabaseService | null = null;
293
294async function getDatabaseService() {
295	if (!DatabaseService) {
296		const module = await import('./database.service');
297
298		DatabaseService = module.DatabaseService;
299	}
300
301	return DatabaseService;
302}
303
304const legacyMessageMigration: Migration = {
305	description: 'Migrate legacy marker-based messages to structured format',
306	id: LEGACY_MESSAGE_MIGRATION_ID,
307
308	async run(): Promise<void> {
309		const db = await getDatabaseService();
310		const conversations = await db.getAllConversations();
311
312		let migratedCount = 0;
313
314		for (const conv of conversations) {
315			const allMessages = await db.getConversationMessages(conv.id);
316
317			for (const message of allMessages) {
318				if (message.role !== MessageRole.ASSISTANT) {
319					if (message.content?.includes(LEGACY_REASONING_TAGS.START)) {
320						const { cleanContent, reasoning } = extractLegacyReasoning(message.content);
321
322						await db.updateMessage(message.id, {
323							content: cleanContent.trim(),
324							reasoningContent: reasoning || undefined
325						});
326						migratedCount++;
327					}
328
329					continue;
330				}
331
332				if (!hasLegacyMarkers(message.content ?? '')) continue;
333
334				const { cleanContent, reasoning } = extractLegacyReasoning(message.content);
335				const turns = parseLegacyToolCalls(cleanContent);
336
337				let existingToolCalls: Array<{
338					id: string;
339					function?: { name: string; arguments: string };
340				}> = [];
341
342				if (message.toolCalls) {
343					try {
344						existingToolCalls = JSON.parse(message.toolCalls);
345					} catch {
346						// Ignore
347					}
348				}
349
350				const firstTurn = turns[0];
351
352				if (!firstTurn) continue;
353
354				const firstTurnToolCalls = firstTurn.toolCalls.map((tc, i) => {
355					const existing =
356						existingToolCalls.find((e) => e.function?.name === tc.name) || existingToolCalls[i];
357
358					return {
359						function: { arguments: tc.args, name: tc.name },
360						id: existing?.id || `legacy_tool_${i}`,
361						type: 'function' as const
362					};
363				});
364
365				await db.updateMessage(message.id, {
366					content: firstTurn.textBefore,
367					reasoningContent: reasoning || undefined,
368					toolCalls: firstTurnToolCalls.length > 0 ? JSON.stringify(firstTurnToolCalls) : ''
369				});
370
371				let currentParentId = message.id;
372				let toolCallIdCounter = existingToolCalls.length;
373
374				for (let i = 0; i < firstTurn.toolCalls.length; i++) {
375					const tc = firstTurn.toolCalls[i];
376					const toolCallId = firstTurnToolCalls[i]?.id || `legacy_tool_${i}`;
377					const toolMsg = await db.createMessageBranch(
378						{
379							children: [],
380							content: tc.result,
381							convId: conv.id,
382							role: MessageRole.TOOL,
383							timestamp: message.timestamp + i + 1,
384							toolCallId,
385							toolCalls: '',
386							type: 'text'
387						},
388						currentParentId
389					);
390
391					currentParentId = toolMsg.id;
392				}
393
394				for (let turnIdx = 1; turnIdx < turns.length; turnIdx++) {
395					const turn = turns[turnIdx];
396					const turnToolCalls = turn.toolCalls.map((tc, i) => {
397						const idx = toolCallIdCounter + i;
398						const existing = existingToolCalls[idx];
399
400						return {
401							function: { arguments: tc.args, name: tc.name },
402							id: existing?.id || `legacy_tool_${idx}`,
403							type: 'function' as const
404						};
405					});
406
407					toolCallIdCounter += turn.toolCalls.length;
408
409					const assistantMsg = await db.createMessageBranch(
410						{
411							children: [],
412							content: turn.textBefore,
413							convId: conv.id,
414							model: message.model,
415							role: MessageRole.ASSISTANT,
416							timestamp: message.timestamp + turnIdx * 100,
417							toolCalls: turnToolCalls.length > 0 ? JSON.stringify(turnToolCalls) : '',
418							type: 'text'
419						},
420						currentParentId
421					);
422
423					currentParentId = assistantMsg.id;
424
425					for (let i = 0; i < turn.toolCalls.length; i++) {
426						const tc = turn.toolCalls[i];
427						const toolCallId = turnToolCalls[i]?.id || `legacy_tool_${toolCallIdCounter + i}`;
428						const toolMsg = await db.createMessageBranch(
429							{
430								children: [],
431								content: tc.result,
432								convId: conv.id,
433								role: MessageRole.TOOL,
434								timestamp: message.timestamp + turnIdx * 100 + i + 1,
435								toolCallId,
436								toolCalls: '',
437								type: 'text'
438							},
439							currentParentId
440						);
441
442						currentParentId = toolMsg.id;
443					}
444				}
445
446				if (message.children.length > 0 && currentParentId !== message.id) {
447					for (const childId of message.children) {
448						const child = allMessages.find((m) => m.id === childId);
449
450						if (!child) continue;
451
452						if (child.role !== MessageRole.TOOL) {
453							await db.updateMessage(childId, { parent: currentParentId });
454						}
455					}
456					await db.updateMessage(message.id, { children: [] });
457				}
458
459				migratedCount++;
460			}
461		}
462
463		if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
464			console.log(`[Migration] Legacy messages: migrated ${migratedCount} messages`);
465	}
466};
467
468// Migration 4: Theme Key (Non-Destructive)
469
470// eslint-disable-next-line padding-line-between-statements -- comment header separates this const group
471const THEME_MIGRATION_ID = 'theme-key-v1';
472const themeMigration: Migration = {
473	description: 'Copy standalone theme key to config object (non-destructive)',
474	id: THEME_MIGRATION_ID,
475
476	async run(): Promise<void> {
477		const legacyTheme = localStorage.getItem('theme');
478
479		if (legacyTheme === null) {
480			if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
481				console.log('[Migration] Theme: no legacy theme key found, skipping');
482
483			return;
484		}
485
486		// Check if config already has theme
487		const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
488		const config = configRaw ? JSON.parse(configRaw) : {};
489
490		if (SETTINGS_KEYS.THEME in config) {
491			if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
492				console.log('[Migration] Theme: config already has theme, skipping');
493
494			return;
495		}
496
497		config[SETTINGS_KEYS.THEME] = legacyTheme;
498		localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
499
500		// Non-destructive: DO NOT delete legacy theme key - keep for downgrade compatibility
501		if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
502			console.log(`[Migration] Theme: copied standalone theme to config (preserved old key)`);
503	}
504};
505
506// Migration Registry & Runner
507
508// eslint-disable-next-line padding-line-between-statements -- comment header separates this const group
509const CUSTOM_JSON_MIGRATION_ID = 'custom-json-key-v1';
510const customJsonKeyMigration: Migration = {
511	description: 'Copy legacy custom config key to customJson (non-destructive)',
512	id: CUSTOM_JSON_MIGRATION_ID,
513
514	async run(): Promise<void> {
515		const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
516
517		if (configRaw === null) return;
518
519		const config = JSON.parse(configRaw);
520
521		if (!('custom' in config)) return;
522
523		if (SETTINGS_KEYS.CUSTOM_JSON in config) return;
524
525		config[SETTINGS_KEYS.CUSTOM_JSON] = config.custom;
526		localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
527
528		// Non-destructive: keep the legacy custom key for downgrade compatibility
529		if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
530			console.log(`[Migration] Custom JSON: copied custom to customJson (preserved old key)`);
531	}
532};
533const MCP_DEFAULT_ENABLED_MIGRATION_ID = 'mcp-default-enabled-to-config-v1';
534const LEGACY_MCP_DEFAULT_ENABLED_KEY = `${STORAGE_APP_NAME}.mcpDefaultEnabled`;
535const DEPRECATED_LEGACY_MCP_DEFAULT_ENABLED_KEY = `${STORAGE_APP_NAME_DEPRECATED}.mcpDefaultEnabled`;
536const mcpDefaultEnabledMigration: Migration = {
537	description:
538		'Copy mcpDefaultEnabled localStorage key into settings config (preserves legacy keys)',
539	id: MCP_DEFAULT_ENABLED_MIGRATION_ID,
540
541	async run(): Promise<void> {
542		const raw =
543			localStorage.getItem(LEGACY_MCP_DEFAULT_ENABLED_KEY) ??
544			localStorage.getItem(DEPRECATED_LEGACY_MCP_DEFAULT_ENABLED_KEY);
545
546		// Legacy keys intentionally left in place so a downgrade keeps reading them.
547
548		if (raw === null) {
549			if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
550				console.log('[Migration] MCP default enabled: no legacy key found, skipping');
551
552			return;
553		}
554
555		const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
556		const config = configRaw ? JSON.parse(configRaw) : {};
557
558		// Don't overwrite an existing config entry — current data wins.
559		if (MCP_DEFAULT_OVERRIDES_LEGACY_KEY in config) {
560			if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
561				console.log('[Migration] MCP default enabled: config already has overrides, skipping');
562
563			return;
564		}
565
566		try {
567			const parsed = JSON.parse(raw);
568
569			if (!Array.isArray(parsed)) return;
570
571			const valid = parsed.every(
572				(o) =>
573					typeof o === 'object' &&
574					o !== null &&
575					typeof (o as Record<string, unknown>).serverId === 'string' &&
576					typeof (o as Record<string, unknown>).enabled === 'boolean'
577			);
578
579			if (!valid) return;
580		} catch {
581			return;
582		}
583
584		config[MCP_DEFAULT_OVERRIDES_LEGACY_KEY] = raw;
585		localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
586
587		if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
588			console.log('[Migration] MCP default enabled: moved legacy key into config');
589	}
590};
591const CONFIG_TYPES_MIGRATION_ID = 'config-type-normalization-v1';
592const configTypesMigration: Migration = {
593	description: 'Coerce legacy string-encoded booleans in persisted config to real booleans',
594	id: CONFIG_TYPES_MIGRATION_ID,
595
596	async run(): Promise<void> {
597		const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
598
599		if (configRaw === null) return;
600
601		const config = JSON.parse(configRaw);
602
603		let changed = false;
604
605		// Pre-schema configs persisted booleans as "true"/"false" strings; the strict server
606		// schema rejects them. No config string field holds exactly "true"/"false", so the
607		// match is unambiguous.
608		for (const key of Object.keys(config)) {
609			if (config[key] === BooleanString.TRUE) {
610				config[key] = true;
611				changed = true;
612			} else if (config[key] === BooleanString.FALSE) {
613				config[key] = false;
614				changed = true;
615			}
616		}
617
618		if (changed) {
619			localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
620		}
621
622		if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
623			console.log(`[Migration] Config types: coerced string booleans (changed=${changed})`);
624	}
625};
626const RENDER_KEYS_MIGRATION_ID = 'render-keys-unfold-v1';
627const LEGACY_RENDER_RAW_TEXT_KEY = 'renderContentAsRawText';
628const renderKeysMigration: Migration = {
629	description: 'Unfold the single raw text render toggle onto the per-surface render keys',
630	id: RENDER_KEYS_MIGRATION_ID,
631
632	async run(): Promise<void> {
633		const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
634
635		if (configRaw === null) return;
636
637		const config = JSON.parse(configRaw);
638
639		if (!(LEGACY_RENDER_RAW_TEXT_KEY in config)) return;
640
641		// The toggle carried user content and thinking at once and cannot say which surface
642		// was chosen, so it only restores the user key and thinking keeps its own default.
643		if (!(SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN in config)) {
644			config[SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN] =
645				config[LEGACY_RENDER_RAW_TEXT_KEY] !== true;
646		}
647
648		// Dropped rather than preserved: the two render keys and the toggle describe the same
649		// surfaces, so leaving it behind would let a stale value fight the restored one.
650		delete config[LEGACY_RENDER_RAW_TEXT_KEY];
651		localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
652
653		if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
654			console.log('[Migration] Render keys: unfolded the raw text toggle');
655	}
656};
657const MCP_DEFAULT_OVERRIDES_LEGACY_KEY = `${STORAGE_APP_NAME}.mcpDefaultServerOverrides`;
658const MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID = 'mcp-default-overrides-merge-v1';
659/**
660 * Folds `mcpDefaultServerOverrides` (the legacy "default for new chats" list,
661 * JSON-encoded as `[{ serverId, enabled }, ...]`) into `mcpServers[i].enabled`.
662 * The legacy override key is intentionally left in the config so a downgrade
663 * keeps reading it. Runs after `mcpDefaultEnabledMigration` so any legacy
664 * standalone overrides are already inside the config.
665 */
666const mcpDefaultOverridesMergeMigration: Migration = {
667	description:
668		'Merge mcpDefaultServerOverrides entries onto mcpServers[i].enabled (preserves legacy key)',
669	id: MCP_DEFAULT_OVERRIDES_MERGE_MIGRATION_ID,
670
671	async run(): Promise<void> {
672		const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
673
674		if (configRaw === null) return;
675
676		const config = JSON.parse(configRaw);
677		const raw = config[MCP_DEFAULT_OVERRIDES_LEGACY_KEY];
678
679		if (typeof raw !== 'string' || raw.length === 0) {
680			if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
681				console.log('[Migration] MCP default overrides merge: nothing to merge');
682
683			return;
684		}
685
686		let overrides: { serverId: string; enabled: boolean }[];
687
688		try {
689			const parsed = JSON.parse(raw);
690
691			if (!Array.isArray(parsed)) return;
692
693			overrides = parsed.filter(
694				(o) =>
695					typeof o === 'object' &&
696					o !== null &&
697					typeof (o as Record<string, unknown>).serverId === 'string' &&
698					typeof (o as Record<string, unknown>).enabled === 'boolean'
699			) as { serverId: string; enabled: boolean }[];
700		} catch {
701			return;
702		}
703
704		const serversRaw = config[SETTINGS_KEYS.MCP_SERVERS];
705
706		let servers: { id: string; enabled?: boolean }[];
707
708		try {
709			servers = typeof serversRaw === 'string' ? JSON.parse(serversRaw) : [];
710		} catch {
711			return;
712		}
713
714		if (!Array.isArray(servers)) servers = [];
715
716		let serversChanged = false;
717
718		const knownIds = new Set(servers.map((s) => s.id));
719
720		for (const override of overrides) {
721			if (!knownIds.has(override.serverId)) continue;
722
723			const index = servers.findIndex((s) => s.id === override.serverId);
724
725			if (index >= 0 && servers[index].enabled !== override.enabled) {
726				servers[index] = { ...servers[index], enabled: override.enabled };
727				serversChanged = true;
728			}
729		}
730
731		if (serversChanged) {
732			config[SETTINGS_KEYS.MCP_SERVERS] = JSON.stringify(servers);
733			localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
734		}
735
736		if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
737			console.log(
738				`[Migration] MCP default overrides merge: applied=${overrides.length} serversChanged=${serversChanged} (legacy key preserved)`
739			);
740	}
741};
742const MCP_SERVER_OVERRIDES_TO_TOOL_POLICY_MIGRATION_ID = 'mcp-server-overrides-to-tool-policy-v1';
743const mcpServerOverridesToToolPolicyMigration: Migration = {
744	description:
745		'Seed per-conversation disabled tool keys from the global defaults and legacy per-conversation MCP server overrides (legacy field preserved)',
746	id: MCP_SERVER_OVERRIDES_TO_TOOL_POLICY_MIGRATION_ID,
747
748	async run(): Promise<void> {
749		// The global disabled set used to apply to every conversation; it is now
750		// the defaults seeded into newly created conversations, so existing rows
751		// are seeded with it to keep their behavior unchanged.
752		let defaults: string[] = [];
753
754		try {
755			const raw = localStorage.getItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
756
757			if (raw) {
758				const parsed: unknown = JSON.parse(raw);
759
760				if (Array.isArray(parsed)) {
761					defaults = parsed.filter((k): k is string => typeof k === 'string');
762				}
763			}
764		} catch {
765			// fall through with empty defaults so legacy overrides still migrate
766		}
767
768		const db = await getDatabaseService();
769		const conversations = await db.getAllConversations();
770
771		let migratedCount = 0;
772
773		for (const conv of conversations) {
774			// re-run safety: a row that already has a policy is left alone
775			if (conv.disabledTools !== undefined) continue;
776
777			// A legacy per-conversation server disable becomes a server-scoped tool
778			// key (same format as toolsStore.getMcpServerToolsKey). Per-conversation
779			// enables are dropped: the global server flag governs now.
780			const serverGroupKeys = (conv.mcpServerOverrides ?? [])
781				.filter((o: McpServerOverride) => !o.enabled)
782				.map((o: McpServerOverride) => `mcp:${o.serverId}`);
783			const disabledTools = [...new Set([...defaults, ...serverGroupKeys])];
784
785			if (disabledTools.length === 0) continue;
786
787			await db.updateConversation(conv.id, { disabledTools });
788			migratedCount++;
789		}
790
791		if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
792			console.log(
793				`[Migration] MCP server overrides -> tool policy: updated ${migratedCount} conversations`
794			);
795	}
796};
797const migrations: Migration[] = [
798	localStorageMigration,
799	idxdbMigration,
800	legacyMessageMigration,
801	themeMigration,
802	customJsonKeyMigration,
803	mcpDefaultEnabledMigration,
804	mcpDefaultOverridesMergeMigration,
805	configTypesMigration,
806	renderKeysMigration,
807	mcpServerOverridesToToolPolicyMigration
808];
809
810export const MigrationService = {
811	/**
812	 * Get all registered migrations
813	 */
814	getMigrations(): Migration[] {
815		return [...migrations];
816	},
817
818	/**
819	 * Get current migration state
820	 */
821	getState(): MigrationState {
822		return getMigrationState();
823	},
824
825	/**
826	 * Check if a specific migration has been completed
827	 */
828	isCompleted(id: string): boolean {
829		return isMigrationCompleted(id);
830	},
831
832	/**
833	 * Reset migration state (use with caution - migrations will run again)
834	 */
835	resetState(): void {
836		localStorage.removeItem(MIGRATION_STATE_KEY);
837
838		if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
839			console.log('[Migration] State reset - all migrations will run again');
840	},
841
842	/**
843	 * Run all pending migrations (non-destructive - preserves legacy data)
844	 * Should be called once at app initialization
845	 */
846	async runAllMigrations(): Promise<void> {
847		const state = getMigrationState();
848
849		if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
850			console.log('[Migration] Starting migration run, state:', state);
851
852		for (const migration of migrations) {
853			if (isMigrationCompleted(migration.id)) {
854				if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
855					console.log(`[Migration] ${migration.id}: already completed, skipping`);
856
857				continue;
858			}
859
860			try {
861				if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
862					console.log(`[Migration] ${migration.id}: running...`);
863
864				await migration.run();
865				markMigrationCompleted(migration.id);
866
867				if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
868					console.log(`[Migration] ${migration.id}: completed successfully`);
869			} catch (error) {
870				console.error(`[Migration] ${migration.id}: failed`, error);
871				markMigrationFailed(migration.id);
872			}
873		}
874
875		if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
876			console.log('[Migration] All migrations complete');
877	}
878};