Skip to content

Commit db84c27

Browse files
authored
fix: migrate venv paths for cross-editor compatibility and legacy naming (#188)
1 parent 1df838b commit db84c27

File tree

4 files changed

+255
-3
lines changed

4 files changed

+255
-3
lines changed

src/kernels/deepnote/deepnoteToolkitInstaller.node.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@ export class DeepnoteToolkitInstaller implements IDeepnoteToolkitInstaller {
8282
const venvKey = venvPath.fsPath;
8383

8484
logger.info(`Ensuring virtual environment at ${venvKey}`);
85+
logger.info(`Base interpreter: ${baseInterpreter.uri.fsPath}`);
86+
87+
// Validate that venv path is in current globalStorage (not from a different editor like VS Code)
88+
const expectedStoragePrefix = this.context.globalStorageUri.fsPath;
89+
if (!venvKey.startsWith(expectedStoragePrefix)) {
90+
const error = new Error(
91+
`Venv path mismatch! Expected venv under ${expectedStoragePrefix} but got ${venvKey}. ` +
92+
`This might happen if the notebook was previously used in a different editor (VS Code vs Cursor).`
93+
);
94+
logger.error(error.message);
95+
throw error;
96+
}
8597

8698
// Wait for any pending installation for this venv to complete
8799
const pendingInstall = this.pendingInstallations.get(venvKey);

src/kernels/deepnote/environments/deepnoteEnvironmentManager.node.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { inject, injectable, named } from 'inversify';
2+
import * as path from '../../../platform/vscode-path/path';
23
import { CancellationToken, EventEmitter, l10n, Uri } from 'vscode';
34
import { IExtensionSyncActivationService } from '../../../platform/activation/types';
45
import { Cancellation } from '../../../platform/common/cancellation';
@@ -52,10 +53,40 @@ export class DeepnoteEnvironmentManager implements IExtensionSyncActivationServi
5253
const configs = await this.storage.loadEnvironments();
5354
this.environments.clear();
5455

56+
let needsMigration = false;
57+
5558
for (const config of configs) {
59+
const venvDirName = path.basename(config.venvPath.fsPath);
60+
61+
// Check if venv path is under current globalStorage
62+
const expectedVenvParent = Uri.joinPath(this.context.globalStorageUri, 'deepnote-venvs').fsPath;
63+
const actualVenvParent = path.dirname(config.venvPath.fsPath);
64+
const isInCorrectStorage = actualVenvParent === expectedVenvParent;
65+
66+
// Check if directory name matches the environment ID and is in correct storage
67+
const isExpectedPath = venvDirName === config.id && isInCorrectStorage;
68+
const needsPathMigration = !isExpectedPath;
69+
70+
if (needsPathMigration) {
71+
logger.info(
72+
`Migrating environment "${config.name}" from ${config.venvPath.fsPath} to ID-based path`
73+
);
74+
75+
config.venvPath = Uri.joinPath(this.context.globalStorageUri, 'deepnote-venvs', config.id);
76+
config.toolkitVersion = undefined;
77+
78+
logger.info(`New venv path: ${config.venvPath.fsPath} (will be recreated on next use)`);
79+
needsMigration = true;
80+
}
81+
5682
this.environments.set(config.id, config);
5783
}
5884

85+
if (needsMigration) {
86+
logger.info('Saving migrated environments to storage');
87+
await this.persistEnvironments();
88+
}
89+
5990
logger.info(`Initialized environment manager with ${this.environments.size} environments`);
6091

6192
// Fire event to notify tree view of loaded environments

src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,4 +266,189 @@ suite('DeepnoteEnvironmentManager', () => {
266266
// Should not throw
267267
});
268268
});
269+
270+
suite('environment migration', () => {
271+
test('should migrate hash-based venv paths to UUID-based paths', async () => {
272+
const oldHashBasedConfig = {
273+
id: 'abcd1234-5678-90ab-cdef-123456789012',
274+
name: 'Old Hash Config',
275+
pythonInterpreter: testInterpreter,
276+
venvPath: Uri.file('/global/storage/deepnote-venvs/venv_7626587d-1.0.0'),
277+
createdAt: new Date(),
278+
lastUsedAt: new Date()
279+
};
280+
281+
when(mockStorage.loadEnvironments()).thenResolve([oldHashBasedConfig]);
282+
when(mockStorage.saveEnvironments(anything())).thenResolve();
283+
when(mockContext.globalStorageUri).thenReturn(Uri.file('/global/storage'));
284+
285+
manager.activate();
286+
await manager.waitForInitialization();
287+
288+
const configs = manager.listEnvironments();
289+
assert.strictEqual(configs.length, 1);
290+
291+
// Should have migrated to UUID-based path
292+
assert.strictEqual(
293+
configs[0].venvPath.fsPath,
294+
'/global/storage/deepnote-venvs/abcd1234-5678-90ab-cdef-123456789012'
295+
);
296+
297+
// Should clear toolkit version to force reinstallation
298+
assert.isUndefined(configs[0].toolkitVersion);
299+
300+
// Should have saved the migration
301+
verify(mockStorage.saveEnvironments(anything())).once();
302+
});
303+
304+
test('should migrate VS Code storage paths to Cursor storage paths', async () => {
305+
const vsCodeConfig = {
306+
id: 'cursor-env-id',
307+
name: 'VS Code Environment',
308+
pythonInterpreter: testInterpreter,
309+
venvPath: Uri.file(
310+
'/Library/Application Support/Code/User/globalStorage/deepnote.vscode-deepnote/deepnote-venvs/cursor-env-id'
311+
),
312+
createdAt: new Date(),
313+
lastUsedAt: new Date(),
314+
toolkitVersion: '1.0.0'
315+
};
316+
317+
when(mockStorage.loadEnvironments()).thenResolve([vsCodeConfig]);
318+
when(mockStorage.saveEnvironments(anything())).thenResolve();
319+
when(mockContext.globalStorageUri).thenReturn(
320+
Uri.file('/Library/Application Support/Cursor/User/globalStorage/deepnote.vscode-deepnote')
321+
);
322+
323+
manager.activate();
324+
await manager.waitForInitialization();
325+
326+
const configs = manager.listEnvironments();
327+
assert.strictEqual(configs.length, 1);
328+
329+
// Should have migrated to Cursor storage
330+
assert.match(configs[0].venvPath.fsPath, /Cursor.*deepnote-venvs\/cursor-env-id$/);
331+
332+
// Should clear toolkit version to force reinstallation
333+
assert.isUndefined(configs[0].toolkitVersion);
334+
335+
verify(mockStorage.saveEnvironments(anything())).once();
336+
});
337+
338+
test('should not migrate environments with correct ID-based paths in correct storage', async () => {
339+
const testDate = new Date();
340+
const correctConfig = {
341+
id: '12345678-1234-1234-1234-123456789abc',
342+
name: 'Correct Config',
343+
pythonInterpreter: testInterpreter,
344+
venvPath: Uri.file('/global/storage/deepnote-venvs/12345678-1234-1234-1234-123456789abc'),
345+
createdAt: testDate,
346+
lastUsedAt: testDate,
347+
toolkitVersion: '1.0.0',
348+
packages: []
349+
};
350+
351+
when(mockStorage.loadEnvironments()).thenResolve([correctConfig]);
352+
when(mockStorage.saveEnvironments(anything())).thenResolve();
353+
when(mockContext.globalStorageUri).thenReturn(Uri.file('/global/storage'));
354+
355+
manager.activate();
356+
await manager.waitForInitialization();
357+
358+
const configs = manager.listEnvironments();
359+
assert.strictEqual(configs.length, 1);
360+
361+
// Path should remain unchanged
362+
assert.strictEqual(
363+
configs[0].venvPath.fsPath,
364+
'/global/storage/deepnote-venvs/12345678-1234-1234-1234-123456789abc'
365+
);
366+
367+
// ID and name should be preserved
368+
assert.strictEqual(configs[0].id, '12345678-1234-1234-1234-123456789abc');
369+
assert.strictEqual(configs[0].name, 'Correct Config');
370+
371+
// Should NOT have saved (no migration needed)
372+
verify(mockStorage.saveEnvironments(anything())).never();
373+
});
374+
375+
test('should not migrate environments with non-UUID IDs when path already matches', async () => {
376+
const testDate = new Date();
377+
const customIdConfig = {
378+
id: 'my-custom-env-id',
379+
name: 'Custom ID Environment',
380+
pythonInterpreter: testInterpreter,
381+
venvPath: Uri.file('/global/storage/deepnote-venvs/my-custom-env-id'),
382+
createdAt: testDate,
383+
lastUsedAt: testDate,
384+
toolkitVersion: '1.0.0'
385+
};
386+
387+
when(mockStorage.loadEnvironments()).thenResolve([customIdConfig]);
388+
when(mockStorage.saveEnvironments(anything())).thenResolve();
389+
when(mockContext.globalStorageUri).thenReturn(Uri.file('/global/storage'));
390+
391+
manager.activate();
392+
await manager.waitForInitialization();
393+
394+
const configs = manager.listEnvironments();
395+
assert.strictEqual(configs.length, 1);
396+
397+
// Path should remain unchanged
398+
assert.strictEqual(configs[0].venvPath.fsPath, '/global/storage/deepnote-venvs/my-custom-env-id');
399+
400+
// Toolkit version should NOT be cleared
401+
assert.strictEqual(configs[0].toolkitVersion, '1.0.0');
402+
403+
// Should NOT have saved (no migration needed)
404+
verify(mockStorage.saveEnvironments(anything())).never();
405+
});
406+
407+
test('should migrate multiple environments at once', async () => {
408+
const configs = [
409+
{
410+
id: 'uuid1',
411+
name: 'Hash Config',
412+
pythonInterpreter: testInterpreter,
413+
venvPath: Uri.file('/global/storage/deepnote-venvs/venv_abc123-1.0.0'),
414+
createdAt: new Date(),
415+
lastUsedAt: new Date()
416+
},
417+
{
418+
id: 'uuid2',
419+
name: 'VS Code Config',
420+
pythonInterpreter: testInterpreter,
421+
venvPath: Uri.file('/Code/globalStorage/deepnote-venvs/uuid2'),
422+
createdAt: new Date(),
423+
lastUsedAt: new Date()
424+
},
425+
{
426+
id: 'uuid3',
427+
name: 'Correct Config',
428+
pythonInterpreter: testInterpreter,
429+
venvPath: Uri.file('/global/storage/deepnote-venvs/uuid3'),
430+
createdAt: new Date(),
431+
lastUsedAt: new Date()
432+
}
433+
];
434+
435+
when(mockStorage.loadEnvironments()).thenResolve(configs);
436+
when(mockStorage.saveEnvironments(anything())).thenResolve();
437+
when(mockContext.globalStorageUri).thenReturn(Uri.file('/global/storage'));
438+
439+
manager.activate();
440+
await manager.waitForInitialization();
441+
442+
const loaded = manager.listEnvironments();
443+
assert.strictEqual(loaded.length, 3);
444+
445+
// First two should be migrated
446+
assert.strictEqual(loaded[0].venvPath.fsPath, '/global/storage/deepnote-venvs/uuid1');
447+
assert.strictEqual(loaded[1].venvPath.fsPath, '/global/storage/deepnote-venvs/uuid2');
448+
// Third should remain unchanged
449+
assert.strictEqual(loaded[2].venvPath.fsPath, '/global/storage/deepnote-venvs/uuid3');
450+
451+
verify(mockStorage.saveEnvironments(anything())).once();
452+
});
453+
});
269454
});

src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -508,9 +508,30 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector,
508508
const existingEnvironmentId = this.notebookEnvironmentsIds.get(notebookKey);
509509

510510
if (existingEnvironmentId != null && existingController != null && existingEnvironmentId === configuration.id) {
511-
logger.info(`Existing controller found for notebook ${getDisplayPath(notebook.uri)}, selecting it`);
512-
await this.ensureControllerSelectedForNotebook(notebook, existingController, progressToken);
513-
return;
511+
logger.info(`Existing controller found for notebook ${getDisplayPath(notebook.uri)}, verifying connection`);
512+
513+
// Verify the controller's interpreter path matches the expected venv path
514+
// This handles cases where notebooks were used in VS Code and now opened in Cursor
515+
const existingInterpreter = existingController.connection.interpreter;
516+
if (existingInterpreter) {
517+
const expectedInterpreter =
518+
process.platform === 'win32'
519+
? Uri.joinPath(configuration.venvPath, 'Scripts', 'python.exe')
520+
: Uri.joinPath(configuration.venvPath, 'bin', 'python');
521+
522+
if (existingInterpreter.uri.fsPath !== expectedInterpreter.fsPath) {
523+
logger.warn(
524+
`Controller interpreter path mismatch! Expected: ${expectedInterpreter.fsPath}, Got: ${existingInterpreter.uri.fsPath}. Recreating controller.`
525+
);
526+
// Dispose old controller and recreate it
527+
existingController.dispose();
528+
this.notebookControllers.delete(notebookKey);
529+
} else {
530+
logger.info(`Controller verified, selecting it`);
531+
await this.ensureControllerSelectedForNotebook(notebook, existingController, progressToken);
532+
return;
533+
}
534+
}
514535
}
515536

516537
// Ensure server is running (startServer is idempotent - returns early if already running)
@@ -582,6 +603,9 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector,
582603
? Uri.joinPath(configuration.venvPath, 'Scripts', 'python.exe')
583604
: Uri.joinPath(configuration.venvPath, 'bin', 'python');
584605

606+
logger.info(`Using venv path: ${configuration.venvPath.fsPath}`);
607+
logger.info(`Venv interpreter path: ${venvInterpreter.fsPath}`);
608+
585609
// CRITICAL: Use unique notebook-based ID (includes query with notebook ID)
586610
// This ensures each notebook gets its own controller/kernel, even within the same project.
587611
// When switching environments, addOrUpdate will call updateConnection() on the existing

0 commit comments

Comments
 (0)