diff --git a/src/main/notebook/ipc.test.ts b/src/main/notebook/ipc.test.ts index 2ce08c80..a849fcba 100644 --- a/src/main/notebook/ipc.test.ts +++ b/src/main/notebook/ipc.test.ts @@ -31,6 +31,10 @@ describe('notebook IPC handlers', () => { }), runCell: vi.fn().mockResolvedValue({ runId: 'run-2', status: 'completed' }), exportIpynb: vi.fn().mockResolvedValue({ saved: true, filePath: '/tmp/session.ipynb' }), + importIpynb: vi.fn().mockResolvedValue({ imported: true, cellCount: 1 }), + openInJupyterLab: vi + .fn() + .mockResolvedValue({ opened: true, url: 'http://localhost:8888', alreadyRunning: false }), beginCodeCell: vi.fn().mockResolvedValue({ cellId: 'cell-1', writeId: 'write-1' }), appendCodeCell: vi.fn().mockResolvedValue({ receivedBytes: 5 }), finishCodeCell: vi.fn().mockResolvedValue({ status: 'idle' }), @@ -69,7 +73,13 @@ describe('notebook IPC handlers', () => { }) await handlers.restart({ sessionId: 'session-1', workspaceCwd: '/workspace' }) await handlers.shutdown({ sessionId: 'session-1', workspaceCwd: '/workspace' }) - await handlers.exportIpynb({ sessionId: 'session-1', workspaceCwd: '/workspace', kernel: 'python' }) + await handlers.exportIpynb({ + sessionId: 'session-1', + workspaceCwd: '/workspace', + kernel: 'python' + }) + await handlers.importIpynb({ sessionId: 'session-1', workspaceCwd: '/workspace' }) + await handlers.openInJupyterLab({ sessionId: 'session-1', workspaceCwd: '/workspace' }) expect(service.execute).toHaveBeenCalledWith({ sessionId: 'session-1', @@ -96,6 +106,14 @@ describe('notebook IPC handlers', () => { workspaceCwd: '/workspace', kernel: 'python' }) + expect(service.importIpynb).toHaveBeenCalledWith({ + sessionId: 'session-1', + workspaceCwd: '/workspace' + }) + expect(service.openInJupyterLab).toHaveBeenCalledWith({ + sessionId: 'session-1', + workspaceCwd: '/workspace' + }) }) it('registers every notebook channel and forwards the renderer payload unchanged', async () => { @@ -108,6 +126,12 @@ describe('notebook IPC handlers', () => { runCell: vi.fn().mockResolvedValue({ runId: 'run-1', status: 'completed' }), execute: vi.fn().mockResolvedValue({ runId: 'run-2', status: 'completed' }), exportIpynb: vi.fn().mockResolvedValue({ saved: false }), + importIpynb: vi.fn().mockResolvedValue({ imported: false }), + openInJupyterLab: vi.fn().mockResolvedValue({ + opened: true, + url: 'http://localhost:8888', + alreadyRunning: false + }), restart: vi.fn().mockResolvedValue({ sessionId: 'session-1' }), shutdown: vi.fn().mockResolvedValue({ sessionId: 'session-1', status: 'shutdown' }) } as unknown as NotebookRuntimeService @@ -123,6 +147,8 @@ describe('notebook IPC handlers', () => { 'notebook:execute', 'notebook:export-ipynb', 'notebook:export-ipynb-all', + 'notebook:import-ipynb', + 'notebook:open-jupyterlab', 'notebook:restart', 'notebook:shutdown' ]) @@ -142,6 +168,8 @@ describe('notebook IPC handlers', () => { await ipcHandlers.get('notebook:run-cell')?.(undefined, run) await ipcHandlers.get('notebook:execute')?.(undefined, execute) await ipcHandlers.get('notebook:export-ipynb')?.(undefined, session) + await ipcHandlers.get('notebook:import-ipynb')?.(undefined, session) + await ipcHandlers.get('notebook:open-jupyterlab')?.(undefined, session) await ipcHandlers.get('notebook:restart')?.(undefined, session) await ipcHandlers.get('notebook:shutdown')?.(undefined, session) @@ -153,6 +181,8 @@ describe('notebook IPC handlers', () => { expect(service.runCell).toHaveBeenCalledWith(run) expect(service.execute).toHaveBeenCalledWith(execute) expect(service.exportIpynb).toHaveBeenCalledWith(session) + expect(service.importIpynb).toHaveBeenCalledWith(session) + expect(service.openInJupyterLab).toHaveBeenCalledWith(session) expect(service.restart).toHaveBeenCalledWith(session) expect(service.shutdown).toHaveBeenCalledWith(session) }) diff --git a/src/main/notebook/ipc.ts b/src/main/notebook/ipc.ts index 9bf38673..80278779 100644 --- a/src/main/notebook/ipc.ts +++ b/src/main/notebook/ipc.ts @@ -9,7 +9,9 @@ import type { ExportNotebookKernelRequest, ExportNotebookResult, FinishNotebookCodeCellRequest, + ImportNotebookResult, NotebookRunSummary, + OpenJupyterLabResult, NotebookSessionReference, NotebookSessionRequest, NotebookSessionState, @@ -29,12 +31,12 @@ type NotebookHandlers = { finishCodeCell: ( request: FinishNotebookCodeCellRequest ) => ReturnType - runCell: ( - request: RunNotebookCellRequest - ) => ReturnType + runCell: (request: RunNotebookCellRequest) => ReturnType execute: (request: ExecuteNotebookCodeRequest) => Promise exportIpynb: (request: ExportNotebookKernelRequest) => Promise exportIpynbAll: (request: ExportNotebookAllRequest) => Promise + importIpynb: (request: NotebookSessionRequest) => Promise + openInJupyterLab: (request: NotebookSessionRequest) => Promise restart: (request: NotebookSessionRequest) => Promise shutdown: (request: NotebookSessionRequest) => ReturnType } @@ -50,6 +52,8 @@ const createNotebookHandlers = (service: NotebookRuntimeService): NotebookHandle execute: (request) => service.execute(request), exportIpynb: (request) => service.exportIpynb(request), exportIpynbAll: (request) => service.exportIpynbAll(request), + importIpynb: (request) => service.importIpynb(request), + openInJupyterLab: (request) => service.openInJupyterLab(request), restart: (request) => service.restart(request), shutdown: (request) => service.shutdown(request) }) @@ -85,6 +89,12 @@ const registerNotebookIpcHandlers = (service: NotebookRuntimeService): void => { ipcMain.handle('notebook:export-ipynb-all', (_event, request: ExportNotebookAllRequest) => handlers.exportIpynbAll(request) ) + ipcMain.handle('notebook:import-ipynb', (_event, request: NotebookSessionRequest) => + handlers.importIpynb(request) + ) + ipcMain.handle('notebook:open-jupyterlab', (_event, request: NotebookSessionRequest) => + handlers.openInJupyterLab(request) + ) ipcMain.handle('notebook:restart', (_event, request: NotebookSessionRequest) => handlers.restart(request) ) diff --git a/src/main/notebook/ipynb-import.test.ts b/src/main/notebook/ipynb-import.test.ts new file mode 100644 index 00000000..e86d9ef5 --- /dev/null +++ b/src/main/notebook/ipynb-import.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest' + +import type { NotebookRunDocument } from '../../shared/notebook' +import { runDocumentToIpynb } from './ipynb-export' +import { ipynbToRunRecords, type IpynbImportResult } from './ipynb-import' + +const context = { + importedAt: 1_000, + createId: (() => { + let value = 0 + return () => String(++value) + })() +} + +const importNotebook = (notebook: unknown): IpynbImportResult => + ipynbToRunRecords(notebook, { + importedAt: context.importedAt, + createId: context.createId + }) + +describe('ipynbToRunRecords', () => { + it('validates the nbformat major version and cells array', () => { + expect(() => importNotebook({ nbformat: 3, cells: [] })).toThrow('expected nbformat 4') + expect(() => importNotebook({ nbformat: 4 })).toThrow('cells must be an array') + }) + + it('imports code cells, skips markdown, and reconstructs all supported outputs', () => { + const result = importNotebook({ + nbformat: 4, + nbformat_minor: 5, + metadata: { kernelspec: { name: 'python3', language: 'python' } }, + cells: [ + { cell_type: 'markdown', source: ['# Title\n'] }, + { + cell_type: 'code', + source: ['print("hello")\n', '2 + 2'], + execution_count: 4, + metadata: { + open_science: { kernel: 'python', environment: 'analysis' } + }, + outputs: [ + { output_type: 'stream', name: 'stdout', text: ['hello\n'] }, + { output_type: 'stream', name: 'stderr', text: 'warning\n' }, + { + output_type: 'error', + ename: 'ValueError', + evalue: 'bad value', + traceback: ['line 1\n', 'line 2'] + }, + { + output_type: 'display_data', + data: { 'image/png': 'aW1hZ2U=', 'text/plain': '' }, + metadata: {} + }, + { + output_type: 'display_data', + data: { 'application/json': { answer: 42 } }, + metadata: {} + }, + { + output_type: 'execute_result', + data: { 'text/plain': ['4'] }, + metadata: {}, + execution_count: 4 + } + ] + } + ] + }) + + expect(result.skippedCellCount).toBe(1) + expect(result.runs).toHaveLength(1) + expect(result.runs[0]).toMatchObject({ + source: 'user', + inputKind: 'cell', + kernelKind: 'python', + script: 'print("hello")\n2 + 2', + status: 'imported', + startedAt: 1_000, + executionCount: 4, + environment: 'analysis', + text: { + stdout: 'hello\n', + stderr: 'warning\n', + traceback: 'line 1\nline 2' + } + }) + expect(result.runs[0].outputs).toEqual([ + { type: 'stream', name: 'stdout', text: 'hello\n' }, + { type: 'stream', name: 'stderr', text: 'warning\n' }, + { + type: 'error', + name: 'ValueError', + message: 'bad value', + traceback: 'line 1\nline 2' + }, + { type: 'display', data: { 'image/png': 'aW1hZ2U=', 'text/plain': '' } }, + { type: 'json', data: { answer: 42 } }, + { type: 'text', text: '4' } + ]) + }) + + it('uses kernelspec fallback and restores downgraded bash/repl source markers', () => { + const result = importNotebook({ + nbformat: 4, + metadata: { kernelspec: { name: 'ir', language: 'R' } }, + cells: [ + { + cell_type: 'code', + source: 'print(1)', + execution_count: null, + metadata: {}, + outputs: [] + }, + { + cell_type: 'code', + source: ['%%bash\n', 'pwd'], + execution_count: null, + metadata: { tags: ['open-science-bash'] }, + outputs: [] + }, + { + cell_type: 'code', + source: '%%javascript\nawait host.mcp()', + execution_count: null, + metadata: { open_science: { kernel: 'repl' } }, + outputs: [] + } + ] + }) + + expect(result.runs.map(({ kernelKind, script }) => ({ kernelKind, script }))).toEqual([ + { kernelKind: 'r', script: 'print(1)' }, + { kernelKind: 'bash', script: 'pwd' }, + { kernelKind: 'repl', script: 'await host.mcp()' } + ]) + }) + + it('round-trips the supported Open Science subset in both directions', async () => { + const source = { + nbformat: 4, + nbformat_minor: 5, + metadata: { kernelspec: { display_name: 'Python 3', name: 'python3', language: 'python' } }, + cells: [ + { + cell_type: 'code', + id: 'source-cell', + source: ['x = 1\n', 'x'], + execution_count: 7, + metadata: { open_science: { kernel: 'python', environment: 'analysis' } }, + outputs: [ + { + output_type: 'execute_result', + data: { 'text/plain': '1' }, + metadata: {}, + execution_count: 7 + } + ] + } + ] + } + const imported = importNotebook(source) + const document: NotebookRunDocument = { + version: 1, + projectName: 'default-project', + sessionId: 'session-1', + workspaceCwd: '/workspace', + notebookSessionRoot: '/storage/notebooks/default-project/session-1', + dataRoot: '/storage/notebooks/default-project/session-1/data', + kernel: { + language: 'python', + kernelName: 'python3', + runtimeRoot: '/storage/runtime', + lastKnownStatus: 'idle' + }, + runs: imported.runs, + updatedAt: 1_000 + } + + const exported = await runDocumentToIpynb(document) + expect(exported.cells[0]).toMatchObject({ + source: source.cells[0].source, + execution_count: 7, + outputs: source.cells[0].outputs, + metadata: { + open_science: { kernel: 'python', environment: 'analysis', status: 'imported' } + } + }) + + const reimported = importNotebook(exported) + expect( + reimported.runs.map(({ script, kernelKind, executionCount, outputs }) => ({ + script, + kernelKind, + executionCount, + outputs + })) + ).toEqual( + imported.runs.map(({ script, kernelKind, executionCount, outputs }) => ({ + script, + kernelKind, + executionCount, + outputs + })) + ) + }) +}) diff --git a/src/main/notebook/ipynb-import.ts b/src/main/notebook/ipynb-import.ts new file mode 100644 index 00000000..24226a6e --- /dev/null +++ b/src/main/notebook/ipynb-import.ts @@ -0,0 +1,206 @@ +import type { + NotebookKernelKind, + NotebookOutput, + NotebookRunRecord, + NotebookTextOutput +} from '../../shared/notebook' + +type IpynbImportContext = { + createId: () => string + importedAt: number +} + +type IpynbImportResult = { + runs: NotebookRunRecord[] + skippedCellCount: number +} + +type JsonObject = Record + +const isObject = (value: unknown): value is JsonObject => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const textValue = (value: unknown, field: string): string => { + if (typeof value === 'string') return value + if (Array.isArray(value) && value.every((part) => typeof part === 'string')) { + return value.join('') + } + throw new Error(`Invalid .ipynb ${field}: expected a string or string array.`) +} + +const optionalObject = (value: unknown): JsonObject => (isObject(value) ? value : {}) + +const notebookKernel = (notebook: JsonObject): 'python' | 'r' => { + const metadata = optionalObject(notebook.metadata) + const kernelspec = optionalObject(metadata.kernelspec) + const name = typeof kernelspec.name === 'string' ? kernelspec.name.toLowerCase() : '' + const language = typeof kernelspec.language === 'string' ? kernelspec.language.toLowerCase() : '' + return name === 'ir' || language === 'r' ? 'r' : 'python' +} + +const cellKernel = (cell: JsonObject, fallback: 'python' | 'r'): NotebookKernelKind => { + const metadata = optionalObject(cell.metadata) + const openScience = optionalObject(metadata.open_science) + const kernel = openScience.kernel + if (kernel === 'python' || kernel === 'r' || kernel === 'repl' || kernel === 'bash') { + return kernel + } + const tags = Array.isArray(metadata.tags) ? metadata.tags : [] + if (tags.includes('open-science-bash')) return 'bash' + if (tags.includes('open-science-repl')) return 'repl' + return fallback +} + +const stripKernelMarker = (source: string, kernel: NotebookKernelKind): string => { + if (kernel === 'bash' && source.startsWith('%%bash\n')) return source.slice('%%bash\n'.length) + if (kernel === 'repl' && source.startsWith('%%javascript\n')) { + return source.slice('%%javascript\n'.length) + } + return source +} + +const mimeText = (value: unknown): string | null => { + if (typeof value === 'string') return value + if (Array.isArray(value) && value.every((part) => typeof part === 'string')) { + return value.join('') + } + return null +} + +const mapDisplayData = (dataValue: unknown, executeResult: boolean): NotebookOutput | null => { + if (!isObject(dataValue)) return null + const entries = Object.entries(dataValue) + if (entries.length === 1 && entries[0][0] === 'application/json') { + return { type: 'json', data: entries[0][1] } + } + if (executeResult && entries.length === 1 && entries[0][0] === 'text/plain') { + const text = mimeText(entries[0][1]) + return text === null ? null : { type: 'text', text } + } + + const data: Record = {} + for (const [mime, value] of entries) { + const text = mimeText(value) + if (text !== null) data[mime] = text + } + return Object.keys(data).length > 0 ? { type: 'display', data } : null +} + +const mapOutput = (value: unknown): NotebookOutput | null => { + if (!isObject(value) || typeof value.output_type !== 'string') return null + switch (value.output_type) { + case 'stream': { + const name = value.name === 'stderr' ? 'stderr' : 'stdout' + return { type: 'stream', name, text: textValue(value.text, 'stream output') } + } + case 'error': + return { + type: 'error', + name: typeof value.ename === 'string' ? value.ename : undefined, + message: typeof value.evalue === 'string' ? value.evalue : undefined, + traceback: textValue(value.traceback ?? '', 'error traceback') + } + case 'display_data': + return mapDisplayData(value.data, false) + case 'execute_result': + return mapDisplayData(value.data, true) + default: + return null + } +} + +const textProjection = (outputs: NotebookOutput[]): NotebookTextOutput => { + const stdout = outputs + .filter( + (output): output is Extract => + output.type === 'stream' && output.name === 'stdout' + ) + .map((output) => output.text) + .join('') + const stderr = outputs + .filter( + (output): output is Extract => + output.type === 'stream' && output.name === 'stderr' + ) + .map((output) => output.text) + .join('') + const traceback = outputs + .filter( + (output): output is Extract => output.type === 'error' + ) + .map((output) => output.traceback) + .join('\n') + + return { + stdout, + stderr, + traceback, + plain: [stdout, stderr].filter((text) => text.trim().length > 0) + } +} + +const readExecutionCount = (value: unknown): number | undefined => + typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined + +// Parses the supported nbformat 4 subset into durable, not-yet-executed run records. ID/time +// generation is injected so the projection is deterministic in tests. +const ipynbToRunRecords = ( + notebookValue: unknown, + context: IpynbImportContext +): IpynbImportResult => { + if (!isObject(notebookValue) || notebookValue.nbformat !== 4) { + throw new Error('Unsupported .ipynb format: expected nbformat 4.') + } + if (!Array.isArray(notebookValue.cells)) { + throw new Error('Invalid .ipynb: cells must be an array.') + } + + const fallbackKernel = notebookKernel(notebookValue) + const runs: NotebookRunRecord[] = [] + let skippedCellCount = 0 + + for (const cellValue of notebookValue.cells) { + if (!isObject(cellValue) || cellValue.cell_type !== 'code') { + skippedCellCount += 1 + continue + } + const kernelKind = cellKernel(cellValue, fallbackKernel) + const source = stripKernelMarker(textValue(cellValue.source, 'cell source'), kernelKind) + const outputs = Array.isArray(cellValue.outputs) + ? cellValue.outputs + .map(mapOutput) + .filter((output): output is NotebookOutput => output !== null) + : [] + const metadata = optionalObject(cellValue.metadata) + const openScience = optionalObject(metadata.open_science) + const id = context.createId() + const environment = + (kernelKind === 'python' || kernelKind === 'r') && + typeof openScience.environment === 'string' && + openScience.environment.trim() + ? openScience.environment + : undefined + + runs.push({ + runId: `imported-run-${id}`, + cellId: `imported-cell-${id}`, + source: 'user', + inputKind: 'cell', + kernelKind, + script: source, + status: 'imported', + startedAt: context.importedAt, + executionCount: readExecutionCount(cellValue.execution_count), + text: textProjection(outputs), + outputs, + artifacts: [], + workingFiles: [], + ...(environment ? { environment } : {}) + }) + } + + return { runs, skippedCellCount } +} + +export { ipynbToRunRecords } +export type { IpynbImportContext, IpynbImportResult } diff --git a/src/main/notebook/jupyterlab.test.ts b/src/main/notebook/jupyterlab.test.ts new file mode 100644 index 00000000..484514aa --- /dev/null +++ b/src/main/notebook/jupyterlab.test.ts @@ -0,0 +1,149 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { PassThrough } from 'node:stream' + +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ shell: { openExternal: vi.fn() } })) + +import { JupyterLabManager, type SpawnProcess } from './jupyterlab' + +type FakeChild = ChildProcess & { + stdout: PassThrough + stderr: PassThrough +} + +const fakeChild = (): FakeChild => { + const child = new EventEmitter() as FakeChild + child.stdout = new PassThrough() + child.stderr = new PassThrough() + Object.defineProperties(child, { + exitCode: { value: null, writable: true }, + signalCode: { value: null, writable: true }, + killed: { value: false, writable: true } + }) + child.kill = vi.fn(() => true) + return child +} + +const exit = (child: FakeChild, code: number): void => { + Object.defineProperty(child, 'exitCode', { value: code, writable: true }) + child.emit('exit', code, null) +} + +describe('JupyterLabManager', () => { + it('installs when the probe fails, launches, and opens the reported URL', async () => { + let installed = false + let launched: FakeChild | undefined + const spawnProcess = vi.fn((_command, args) => { + const child = fakeChild() + if (args.includes('--version')) { + queueMicrotask(() => { + exit(child, installed ? 0 : 1) + }) + } else { + launched = child + queueMicrotask(() => child.stderr.write('http://127.0.0.1:4321/lab?token=secret\n')) + } + return child + }) + const ensureInstalled = vi.fn(async () => { + installed = true + }) + const openExternal = vi.fn().mockResolvedValue(undefined) + const manager = new JupyterLabManager({ spawnProcess, openExternal }) + + const result = await manager.launch({ + sessionId: 'session-1', + command: '/env/bin/python', + notebookPath: '/session/data/session.ipynb', + rootDir: '/session/data', + cwd: '/session/data', + ensureInstalled + }) + + expect(result).toEqual({ + url: 'http://127.0.0.1:4321/lab?token=secret', + alreadyRunning: false + }) + expect(ensureInstalled).toHaveBeenCalledOnce() + expect(openExternal).toHaveBeenCalledWith(result.url) + expect(spawnProcess).toHaveBeenLastCalledWith( + '/env/bin/python', + expect.arrayContaining([ + '-m', + 'jupyterlab', + '/session/data/session.ipynb', + '--no-browser', + '--ServerApp.port=0', + '--ServerApp.root_dir=/session/data' + ]), + expect.objectContaining({ cwd: '/session/data' }) + ) + expect(launched).toBeDefined() + }) + + it('reopens an already-running session without spawning another process', async () => { + const launchChild = fakeChild() + const spawnProcess = vi.fn((_command, args) => { + if (args.includes('--version')) { + const probe = fakeChild() + queueMicrotask(() => { + exit(probe, 0) + }) + return probe + } + queueMicrotask(() => launchChild.stdout.write('http://localhost:9999/lab?token=t\n')) + return launchChild + }) + const openExternal = vi.fn().mockResolvedValue(undefined) + const manager = new JupyterLabManager({ spawnProcess, openExternal }) + const request = { + sessionId: 'session-1', + command: 'python', + notebookPath: '/data/session.ipynb', + rootDir: '/data', + cwd: '/data', + ensureInstalled: vi.fn() + } + + await manager.launch(request) + const second = await manager.launch(request) + + expect(second.alreadyRunning).toBe(true) + expect(spawnProcess).toHaveBeenCalledTimes(2) + expect(openExternal).toHaveBeenCalledTimes(2) + }) + + it('terminates tracked process trees during shutdown', async () => { + const launchChild = fakeChild() + const spawnProcess = vi.fn((_command, args) => { + if (args.includes('--version')) { + const probe = fakeChild() + queueMicrotask(() => { + exit(probe, 0) + }) + return probe + } + queueMicrotask(() => launchChild.stdout.write('http://localhost:9999/lab?token=t\n')) + return launchChild + }) + const terminate = vi.fn().mockResolvedValue({ reaped: true }) + const manager = new JupyterLabManager({ + spawnProcess, + openExternal: vi.fn().mockResolvedValue(undefined), + terminate + }) + await manager.launch({ + sessionId: 'session-1', + command: 'python', + notebookPath: '/data/session.ipynb', + rootDir: '/data', + cwd: '/data', + ensureInstalled: vi.fn() + }) + + await expect(manager.shutdownAll()).resolves.toEqual({ reaped: true }) + expect(terminate).toHaveBeenCalledWith(launchChild) + }) +}) diff --git a/src/main/notebook/jupyterlab.ts b/src/main/notebook/jupyterlab.ts new file mode 100644 index 00000000..16cf3cc6 --- /dev/null +++ b/src/main/notebook/jupyterlab.ts @@ -0,0 +1,198 @@ +import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' + +import { shell } from 'electron' + +import { terminateProcessTree, type ProcessTreeKillResult } from '../process-tree' + +type JupyterLabLaunchRequest = { + sessionId: string + command: string + commandArgs?: string[] + notebookPath: string + rootDir: string + cwd: string + ensureInstalled: () => Promise +} + +type JupyterLabLaunchResult = { + url: string + alreadyRunning: boolean +} + +type SpawnProcess = (command: string, args: string[], options: SpawnOptions) => ChildProcess + +type JupyterLabManagerDeps = { + spawnProcess?: SpawnProcess + openExternal?: (url: string) => Promise + terminate?: (child: ChildProcess) => Promise + startupTimeoutMs?: number +} + +type RunningJupyterLab = { + child: ChildProcess + url?: string +} + +const JUPYTER_URL_PATTERN = /https?:\/\/(?:127\.0\.0\.1|localhost):\d+\/[^\s]*/i + +const probeJupyterLab = async ( + spawnProcess: SpawnProcess, + command: string, + commandArgs: string[], + cwd: string, + timeoutMs: number +): Promise => { + let child: ChildProcess + try { + child = spawnProcess(command, [...commandArgs, '-m', 'jupyterlab', '--version'], { + cwd, + windowsHide: true, + stdio: 'ignore' + }) + } catch { + return false + } + return new Promise((resolve) => { + let settled = false + const finish = (available: boolean): void => { + if (settled) return + settled = true + clearTimeout(timeout) + resolve(available) + } + const timeout = setTimeout(() => { + child.kill() + finish(false) + }, timeoutMs) + timeout.unref?.() + child.once('error', () => finish(false)) + child.once('exit', (code) => finish(code === 0)) + }) +} + +class JupyterLabManager { + private readonly running = new Map() + private readonly spawnProcess: SpawnProcess + private readonly openExternal: (url: string) => Promise + private readonly terminate: (child: ChildProcess) => Promise + private readonly startupTimeoutMs: number + + constructor(deps: JupyterLabManagerDeps = {}) { + this.spawnProcess = deps.spawnProcess ?? spawn + this.openExternal = deps.openExternal ?? ((url) => shell.openExternal(url)) + this.terminate = deps.terminate ?? ((child) => terminateProcessTree(child)) + this.startupTimeoutMs = deps.startupTimeoutMs ?? 30_000 + } + + async launch(request: JupyterLabLaunchRequest): Promise { + const existing = this.running.get(request.sessionId) + if (existing?.url && existing.child.exitCode === null) { + await this.openExternal(existing.url) + return { url: existing.url, alreadyRunning: true } + } + + const commandArgs = request.commandArgs ?? [] + const probeTimeoutMs = Math.min(this.startupTimeoutMs, 10_000) + if ( + !(await probeJupyterLab( + this.spawnProcess, + request.command, + commandArgs, + request.cwd, + probeTimeoutMs + )) + ) { + await request.ensureInstalled() + if ( + !(await probeJupyterLab( + this.spawnProcess, + request.command, + commandArgs, + request.cwd, + probeTimeoutMs + )) + ) { + throw new Error('JupyterLab installation completed but the module is still unavailable.') + } + } + + const child = this.spawnProcess( + request.command, + [ + ...commandArgs, + '-m', + 'jupyterlab', + request.notebookPath, + '--no-browser', + '--ServerApp.port=0', + `--ServerApp.root_dir=${request.rootDir}` + ], + { + cwd: request.cwd, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'] + } + ) + const running: RunningJupyterLab = { child } + this.running.set(request.sessionId, running) + + child.once('exit', () => { + if (this.running.get(request.sessionId)?.child === child) { + this.running.delete(request.sessionId) + } + }) + + const url = await new Promise((resolve, reject) => { + let settled = false + let output = '' + const finish = (error: Error | null, value?: string): void => { + if (settled) return + settled = true + clearTimeout(timeout) + if (error) reject(error) + else resolve(value as string) + } + const inspect = (chunk: Buffer | string): void => { + output = `${output}${String(chunk)}`.slice(-16_384) + const match = output.match(JUPYTER_URL_PATTERN) + if (match) finish(null, match[0]) + } + const timeout = setTimeout( + () => finish(new Error('Timed out waiting for JupyterLab to start.')), + this.startupTimeoutMs + ) + timeout.unref?.() + child.stdout?.on('data', inspect) + child.stderr?.on('data', inspect) + child.once('error', (error) => finish(error)) + child.once('exit', (code) => { + finish(new Error(`JupyterLab exited before startup (code ${String(code)}).`)) + }) + }).catch(async (error: unknown) => { + this.running.delete(request.sessionId) + await this.terminate(child) + throw error + }) + + running.url = url + await this.openExternal(url) + return { url, alreadyRunning: false } + } + + async shutdown(sessionId: string): Promise { + const running = this.running.get(sessionId) + if (!running) return { reaped: true } + this.running.delete(sessionId) + return this.terminate(running.child) + } + + async shutdownAll(): Promise { + const children = Array.from(this.running.values(), ({ child }) => child) + this.running.clear() + const results = await Promise.all(children.map((child) => this.terminate(child))) + return { reaped: results.every((result) => result.reaped) } + } +} + +export { JupyterLabManager } +export type { JupyterLabLaunchRequest, JupyterLabLaunchResult, JupyterLabManagerDeps, SpawnProcess } diff --git a/src/main/notebook/repository.ts b/src/main/notebook/repository.ts index 344afa80..a5b29a28 100644 --- a/src/main/notebook/repository.ts +++ b/src/main/notebook/repository.ts @@ -28,6 +28,12 @@ type AppendNotebookRunRequest = { run: NotebookRunRecord } +type AppendNotebookRunsRequest = { + projectName: string + sessionId: string + runs: NotebookRunRecord[] +} + type UpdateNotebookRunRequest = AppendNotebookRunRequest type UpdateKernelStatusRequest = { @@ -219,6 +225,19 @@ class NotebookRunRepository { })) } + // Appends an imported notebook in one queued read-modify-write turn, avoiding one run.json rewrite + // per cell while preserving the same normalization and serialization guarantees as appendRun. + async appendRuns(request: AppendNotebookRunsRequest): Promise { + return this.mutate(request.projectName, request.sessionId, (document) => ({ + ...document, + runs: [ + ...document.runs, + ...request.runs.map((run) => normalizeRun(document.notebookSessionRoot, run)) + ], + updatedAt: Date.now() + })) + } + // Replaces an existing execution record, used to turn the initial "running" entry final. async updateRun(request: UpdateNotebookRunRequest): Promise { return this.mutate(request.projectName, request.sessionId, (document) => { @@ -398,6 +417,7 @@ export { } export type { AppendNotebookRunRequest, + AppendNotebookRunsRequest, LoadNotebookRunDocumentRequest, UpdateKernelStatusRequest, UpdateNotebookRunRequest diff --git a/src/main/notebook/runtime-service.import.test.ts b/src/main/notebook/runtime-service.import.test.ts new file mode 100644 index 00000000..24438fa6 --- /dev/null +++ b/src/main/notebook/runtime-service.import.test.ts @@ -0,0 +1,133 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { NotebookRunDocument } from '../../shared/notebook' +import type { NotebookRunRepository } from './repository' +import { NotebookRuntimeService } from './runtime-service' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +const document = (root: string): NotebookRunDocument => ({ + version: 1, + projectName: 'default-project', + sessionId: 'session-1', + workspaceCwd: '/workspace', + notebookSessionRoot: join(root, 'notebooks', 'default-project', 'session-1'), + dataRoot: join(root, 'notebooks', 'default-project', 'session-1', 'data'), + kernel: { + language: 'python', + kernelName: 'python3', + runtimeRoot: join(root, 'runtime'), + lastKnownStatus: 'idle' + }, + runs: [], + updatedAt: 1 +}) + +const executor = { + execute: vi.fn(), + shutdown: vi.fn().mockResolvedValue({ reaped: true }) +} + +describe('NotebookRuntimeService importIpynb', () => { + it('imports in one repository append and exposes data cells for rerun', async () => { + const root = await mkdtemp(join(tmpdir(), 'open-science-ipynb-import-')) + roots.push(root) + const filePath = join(root, 'source.ipynb') + await writeFile( + filePath, + JSON.stringify({ + nbformat: 4, + metadata: { kernelspec: { name: 'python3', language: 'python' } }, + cells: [ + { + cell_type: 'code', + source: 'print(1)', + execution_count: null, + metadata: {}, + outputs: [] + }, + { cell_type: 'markdown', source: '# ignored' } + ] + }) + ) + const stored = document(root) + const repository = { + loadOrCreate: vi.fn().mockResolvedValue(stored), + appendRuns: vi.fn().mockImplementation(async ({ runs }) => ({ ...stored, runs })) + } as unknown as NotebookRunRepository + const service = new NotebookRuntimeService({ + configRoot: join(root, 'config'), + dataRoot: root, + projectName: 'default-project', + repository, + executorFactory: () => executor, + pickIpynb: async () => filePath + }) + + const result = await service.importIpynb({ + sessionId: 'session-1', + workspaceCwd: '/workspace' + }) + + expect(result).toEqual({ imported: true, cellCount: 1, skippedCellCount: 1 }) + expect(repository.appendRuns).toHaveBeenCalledOnce() + const importedRuns = vi.mocked(repository.appendRuns).mock.calls[0][0].runs + expect(importedRuns[0]).toMatchObject({ + script: 'print(1)', + status: 'imported', + kernelKind: 'python' + }) + const state = await service.state({ sessionId: 'session-1', workspaceCwd: '/workspace' }) + expect(state.cells).toEqual([ + expect.objectContaining({ + id: importedRuns[0].cellId, + language: 'python', + code: 'print(1)', + status: 'idle' + }) + ]) + }) + + it('returns a cancellation result without reading or creating a session', async () => { + const repository = { + loadOrCreate: vi.fn() + } as unknown as NotebookRunRepository + const service = new NotebookRuntimeService({ + configRoot: '/config', + dataRoot: '/storage', + projectName: 'default-project', + repository, + pickIpynb: async () => null + }) + + await expect( + service.importIpynb({ sessionId: 'session-1', workspaceCwd: '/workspace' }) + ).resolves.toEqual({ imported: false }) + expect(repository.loadOrCreate).not.toHaveBeenCalled() + }) + + it('reports invalid JSON as an import error', async () => { + const root = await mkdtemp(join(tmpdir(), 'open-science-ipynb-import-')) + roots.push(root) + const filePath = join(root, 'broken.ipynb') + await writeFile(filePath, '{') + const service = new NotebookRuntimeService({ + configRoot: join(root, 'config'), + dataRoot: root, + projectName: 'default-project', + pickIpynb: async () => filePath + }) + + await expect( + service.importIpynb({ sessionId: 'session-1', workspaceCwd: '/workspace' }) + ).rejects.toThrow('Could not read .ipynb') + }) +}) diff --git a/src/main/notebook/runtime-service.jupyterlab.test.ts b/src/main/notebook/runtime-service.jupyterlab.test.ts new file mode 100644 index 00000000..1707beb2 --- /dev/null +++ b/src/main/notebook/runtime-service.jupyterlab.test.ts @@ -0,0 +1,102 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { NotebookRunDocument } from '../../shared/notebook' +import type { JupyterLabManager, JupyterLabLaunchRequest } from './jupyterlab' +import type { NotebookRunRepository } from './repository' +import { NotebookRuntimeService } from './runtime-service' +import { envPrefix, pythonBin, runtimeRoot } from './runtime-paths' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('NotebookRuntimeService openInJupyterLab', () => { + it('writes the current projection and launches with the bound managed Python', async () => { + const root = await mkdtemp(join(tmpdir(), 'open-science-jupyterlab-')) + roots.push(root) + const document: NotebookRunDocument = { + version: 1, + projectName: 'default-project', + sessionId: '12345678-abcd', + workspaceCwd: '/workspace', + notebookSessionRoot: join(root, 'notebooks', 'default-project', '12345678-abcd'), + dataRoot: join(root, 'notebooks', 'default-project', '12345678-abcd', 'data'), + kernel: { + language: 'python', + kernelName: 'python3', + runtimeRoot: runtimeRoot(root), + lastKnownStatus: 'idle' + }, + runs: [ + { + runId: 'run-1', + cellId: 'cell-1', + source: 'agent', + kernelKind: 'python', + script: 'print(1)', + status: 'completed', + startedAt: 1, + text: { stdout: '', stderr: '', traceback: '', plain: [] }, + outputs: [], + artifacts: [], + workingFiles: [] + } + ], + updatedAt: 2 + } + const repository = { + loadOrCreate: vi.fn().mockResolvedValue(document), + findExisting: vi.fn().mockResolvedValue(document) + } as unknown as NotebookRunRepository + let launchRequest: JupyterLabLaunchRequest | undefined + const manager = { + launch: vi.fn(async (request: JupyterLabLaunchRequest) => { + launchRequest = request + return { url: 'http://localhost:8888/lab?token=x', alreadyRunning: false } + }), + shutdown: vi.fn().mockResolvedValue({ reaped: true }), + shutdownAll: vi.fn().mockResolvedValue({ reaped: true }) + } as unknown as JupyterLabManager + const service = new NotebookRuntimeService({ + configRoot: join(root, 'config'), + dataRoot: root, + projectName: 'default-project', + repository, + executorFactory: () => ({ + execute: vi.fn(), + shutdown: vi.fn().mockResolvedValue({ reaped: true }) + }), + jupyterLabManager: manager + }) + + const result = await service.openInJupyterLab({ + sessionId: '12345678-abcd', + workspaceCwd: '/workspace' + }) + + expect(result).toEqual({ + opened: true, + url: 'http://localhost:8888/lab?token=x', + alreadyRunning: false + }) + expect(launchRequest).toMatchObject({ + sessionId: '12345678-abcd', + command: pythonBin(envPrefix(runtimeRoot(root), 'default-python')), + rootDir: document.dataRoot, + cwd: document.dataRoot + }) + const notebookPath = launchRequest?.notebookPath + expect(notebookPath).toBe(join(document.dataRoot, 'session-12345678.ipynb')) + const written = JSON.parse(await readFile(notebookPath as string, 'utf8')) as { + nbformat: number + cells: Array<{ source: string[] }> + } + expect(written).toMatchObject({ nbformat: 4, cells: [{ source: ['print(1)'] }] }) + }) +}) diff --git a/src/main/notebook/runtime-service.ts b/src/main/notebook/runtime-service.ts index 68832496..8881ef7f 100644 --- a/src/main/notebook/runtime-service.ts +++ b/src/main/notebook/runtime-service.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcess } from 'node:child_process' import { randomUUID } from 'node:crypto' import { existsSync, realpathSync } from 'node:fs' -import { readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { isAbsolute, join, relative, resolve, sep } from 'node:path' import type { @@ -16,11 +16,13 @@ import type { ExportNotebookKernelRequest, ExportNotebookResult, FinishNotebookCodeCellRequest, + ImportNotebookResult, NotebookEnvironmentStatus, NotebookKernelMetadata, NotebookLanguage, NotebookOutput, NotebookRunDocument, + OpenJupyterLabResult, NotebookRunRecord, NotebookRunSource, NotebookRunStatus, @@ -41,11 +43,14 @@ import type { } from '../../shared/notebook-env' import type { PackageMirror } from '../../shared/mirror' import { + runDocumentToIpynb, runDocumentToIpynbByKernel, runDocumentToIpynbForKernel, type NbformatOutput, type ResolvedArtifact } from './ipynb-export' +import { ipynbToRunRecords } from './ipynb-import' +import { JupyterLabManager } from './jupyterlab' import { NotebookKernelExecutor, type NotebookKernelExecutorOptions } from './kernel-executor' import { saveIpynbAll } from './save-ipynb-all' import type { KernelProcessKind } from './kernel-executor' @@ -335,6 +340,9 @@ type NotebookRuntimeServiceOptions = { projectName: string sessionId: string }) => Promise + // Native file-picker seam for notebook import tests. + pickIpynb?: () => Promise + jupyterLabManager?: JupyterLabManager } // The wire binding plus the interpreter override the executor needs. `resolvedInterpreter` is set only @@ -405,6 +413,16 @@ const saveIpynbWithDialog = async ( return { saved: true, filePath } } +const pickIpynbWithDialog = async (): Promise => { + const { dialog } = await import('electron') + const { canceled, filePaths } = await dialog.showOpenDialog({ + title: 'Import notebook', + properties: ['openFile'], + filters: [{ name: 'Jupyter Notebook', extensions: ['ipynb'] }] + }) + return canceled ? null : (filePaths[0] ?? null) +} + // Writes one .ipynb per data kernel under a user-picked directory. Used by the "Download all" path; // the per-tab path (a single .ipynb) goes through `saveIpynbWithDialog` instead. The actual // orchestration (directory picker, conflict check, partial-write cleanup) lives in save-ipynb-all @@ -885,6 +903,7 @@ class EnvConcurrencyLock { // Coordinates notebook cells, shared interpreters, persisted run history, and UI notifications. class NotebookRuntimeService { private readonly repository: NotebookRunRepository + private readonly jupyterLabManager: JupyterLabManager private readonly sessions = new Map() private readonly announcedAgentSessionIds = new Set() // Serializes environment management (installs) against kernel runs on the same language's env; @@ -959,6 +978,7 @@ class NotebookRuntimeService { constructor(private readonly options: NotebookRuntimeServiceOptions) { this.repository = options.repository ?? new NotebookRunRepository(options.dataRoot) + this.jupyterLabManager = options.jupyterLabManager ?? new JupyterLabManager() this.mcpRpcConnectionResolver = options.getMcpRpcConnection this.packageMirrorResolver = options.getPackageMirror this.runtimeEnablementResolver = options.getRuntimeEnablement @@ -2105,6 +2125,112 @@ class NotebookRuntimeService { return (this.options.saveIpynbAll ?? saveIpynbAll)(files) } + // Imports code cells as durable, not-yet-executed records and exposes data-kernel cells for rerun. + async importIpynb(request: NotebookSessionRequest): Promise { + const filePath = await (this.options.pickIpynb ?? pickIpynbWithDialog)() + if (!filePath) return { imported: false } + + let notebook: unknown + try { + notebook = JSON.parse(await readFile(filePath, 'utf8')) as unknown + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Could not read .ipynb: ${message}`) + } + + const imported = ipynbToRunRecords(notebook, { + createId: randomUUID, + importedAt: Date.now() + }) + const session = await this.ensureSession(request) + if (imported.runs.length > 0) { + await this.repository.appendRuns({ + projectName: session.projectName, + sessionId: session.sessionId, + runs: imported.runs + }) + for (const run of imported.runs) { + if (run.kernelKind !== 'python' && run.kernelKind !== 'r') continue + session.cells.push({ + id: run.cellId, + language: run.kernelKind, + code: run.script, + status: 'idle', + executionCount: run.executionCount, + latestRunId: run.runId + }) + } + session.executionCount += imported.runs.length + this.notifyNotebookChanged(session) + } + + return { + imported: true, + cellCount: imported.runs.length, + skippedCellCount: imported.skippedCellCount + } + } + + // Materializes the current projection inside the session data root, ensures JupyterLab is available + // in the session's bound Python runtime, and opens the authenticated local URL it reports. + async openInJupyterLab(request: NotebookSessionRequest): Promise { + const session = await this.ensureSession(request) + const document = await this.repository.findExisting(session.projectName, session.sessionId) + if (!document) { + throw new Error(`Notebook session not found: ${request.sessionId}`) + } + + const binding = session.runtimeBindings.get('python') + if (binding?.status === 'unavailable') { + throw new Error('The bound Python runtime is unavailable.') + } + const environment = this.resolveRunEnv(session, 'python') + const interpreter = + binding?.source === 'external' + ? binding.resolvedInterpreter + : { + command: pythonBin(envPrefix(getRuntimeRoot(this.options.dataRoot), environment)) + } + if (!interpreter) { + throw new Error('The bound Python interpreter could not be resolved.') + } + + const artifactOutputs = await resolveNotebookArtifactOutputs( + document, + this.options.resolveArtifactPath + ) + const notebook = runDocumentToIpynb(document, { + appVersion: this.options.appVersion, + artifactOutputs + }) + await mkdir(document.dataRoot, { recursive: true }) + const notebookPath = join(document.dataRoot, `session-${request.sessionId.slice(0, 8)}.ipynb`) + await writeFile(notebookPath, `${JSON.stringify(notebook, null, 2)}\n`, 'utf8') + + const launched = await this.jupyterLabManager.launch({ + sessionId: session.sessionId, + command: interpreter.command, + commandArgs: interpreter.args, + notebookPath, + rootDir: document.dataRoot, + cwd: document.dataRoot, + ensureInstalled: async () => { + const result = await this.managePackages({ + language: 'python', + packages: ['jupyterlab'], + sessionId: session.sessionId, + workspaceCwd: request.workspaceCwd, + projectName: session.projectName + }) + if (!result.ok) { + throw new Error(result.error || result.log || 'Failed to install JupyterLab.') + } + } + }) + + return { opened: true, ...launched } + } + // Replaces the interpreter process while preserving cells and durable run history. Prefers the // executor's own in-place restart (keeps the same instance, e.g. NotebookKernelExecutor tears down // and lazily respawns its loops) and only shuts down + recreates for executors that don't support it. @@ -2546,6 +2672,7 @@ class NotebookRuntimeService { const session = this.sessions.get(request.sessionId) if (session) { + await this.jupyterLabManager.shutdown(request.sessionId) await session.executor.shutdown() this.sessions.delete(request.sessionId) } @@ -2789,8 +2916,9 @@ class NotebookRuntimeService { const results = await Promise.all( Array.from(this.sessions.values()).map((session) => session.executor.shutdown()) ) + const jupyterResult = await this.jupyterLabManager.shutdownAll() this.sessions.clear() - return { reaped: results.every((result) => result.reaped) } + return { reaped: jupyterResult.reaped && results.every((result) => result.reaped) } } // Lists sessions with a cell mid-execution, for the pre-migration active-session warning. @@ -2836,7 +2964,19 @@ class NotebookRuntimeService { dataRoot: document.dataRoot, runtimeRoot: document.kernel.runtimeRoot, runJsonPath: getNotebookRunJsonPath(this.options.dataRoot, projectName, request.sessionId), - cells: [], + cells: document.runs + .filter( + (run) => + run.status === 'imported' && (run.kernelKind === 'python' || run.kernelKind === 'r') + ) + .map((run) => ({ + id: run.cellId, + language: run.kernelKind as NotebookLanguage, + code: run.script, + status: 'idle' as const, + executionCount: run.executionCount, + latestRunId: run.runId + })), executionCount: document.runs.length, executor: this.createExecutor(request.sessionId, projectName), executionQueues: new Map(), diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 470d2b33..23a251ed 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -57,8 +57,10 @@ import type { ExportNotebookKernelRequest, ExportNotebookResult, FinishNotebookCodeCellRequest, + ImportNotebookResult, NotebookLanguage, NotebookRunSummary, + OpenJupyterLabResult, NotebookSessionReference, NotebookSessionRequest, NotebookSessionState, @@ -421,6 +423,8 @@ interface OpenScienceAPI { execute(request: ExecuteNotebookCodeRequest): Promise exportIpynb(request: ExportNotebookKernelRequest): Promise exportIpynbAll(request: ExportNotebookAllRequest): Promise + importIpynb(request: NotebookSessionRequest): Promise + openInJupyterLab(request: NotebookSessionRequest): Promise restart(request: NotebookSessionRequest): Promise shutdown(request: NotebookSessionRequest): Promise<{ sessionId: string; status: 'shutdown' }> onAvailable(listener: AcpListener): RemoveListener diff --git a/src/preload/index.ts b/src/preload/index.ts index b8a6cddc..527a9521 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -59,8 +59,10 @@ import type { ExportNotebookKernelRequest, ExportNotebookResult, FinishNotebookCodeCellRequest, + ImportNotebookResult, NotebookLanguage, NotebookRunSummary, + OpenJupyterLabResult, NotebookSessionReference, NotebookSessionRequest, NotebookSessionState, @@ -459,6 +461,8 @@ type OpenScienceAPI = { execute: (request: ExecuteNotebookCodeRequest) => Promise exportIpynb: (request: ExportNotebookKernelRequest) => Promise exportIpynbAll: (request: ExportNotebookAllRequest) => Promise + importIpynb: (request: NotebookSessionRequest) => Promise + openInJupyterLab: (request: NotebookSessionRequest) => Promise restart: (request: NotebookSessionRequest) => Promise shutdown: ( request: NotebookSessionRequest @@ -927,15 +931,13 @@ const api: OpenScienceAPI = { execute: (request) => ipcRenderer.invoke('notebook:execute', request) as Promise, exportIpynb: (request) => - ipcRenderer.invoke( - 'notebook:export-ipynb', - request - ) as Promise, + ipcRenderer.invoke('notebook:export-ipynb', request) as Promise, exportIpynbAll: (request) => - ipcRenderer.invoke( - 'notebook:export-ipynb-all', - request - ) as Promise, + ipcRenderer.invoke('notebook:export-ipynb-all', request) as Promise, + importIpynb: (request) => + ipcRenderer.invoke('notebook:import-ipynb', request) as Promise, + openInJupyterLab: (request) => + ipcRenderer.invoke('notebook:open-jupyterlab', request) as Promise, restart: (request) => ipcRenderer.invoke('notebook:restart', request) as Promise, shutdown: (request) => diff --git a/src/renderer/src/pages/workspace/SessionNotebookDialog.interaction.test.tsx b/src/renderer/src/pages/workspace/SessionNotebookDialog.interaction.test.tsx index 6a97e08d..a7cbf912 100644 --- a/src/renderer/src/pages/workspace/SessionNotebookDialog.interaction.test.tsx +++ b/src/renderer/src/pages/workspace/SessionNotebookDialog.interaction.test.tsx @@ -51,6 +51,8 @@ describe('SessionNotebookContent export', () => { onClose={vi.fn()} onExport={onExport} onExportAll={vi.fn()} + onImport={vi.fn()} + onOpenJupyterLab={vi.fn()} /> ) }) @@ -72,7 +74,10 @@ describe('SessionNotebookContent export', () => { it('passes the clicked tab kernel to the export callback after switching tabs', async () => { const onExport = vi.fn().mockResolvedValue(undefined) - const mixedRuns: NotebookRunRecord[] = [run, { ...run, runId: 'r1', kernelKind: 'r', environment: 'default-r' }] + const mixedRuns: NotebookRunRecord[] = [ + run, + { ...run, runId: 'r1', kernelKind: 'r', environment: 'default-r' } + ] await act(async () => { root.render( { onClose={vi.fn()} onExport={onExport} onExportAll={vi.fn()} + onImport={vi.fn()} + onOpenJupyterLab={vi.fn()} /> ) }) @@ -118,6 +125,8 @@ describe('SessionNotebookContent export', () => { onClose={vi.fn()} onExport={onExport} onExportAll={vi.fn()} + onImport={vi.fn()} + onOpenJupyterLab={vi.fn()} /> ) }) @@ -146,6 +155,8 @@ describe('SessionNotebookContent export', () => { onClose={vi.fn()} onExport={onExport} onExportAll={vi.fn()} + onImport={vi.fn()} + onOpenJupyterLab={vi.fn()} /> ) }) @@ -172,6 +183,8 @@ describe('SessionNotebookContent export', () => { onClose={vi.fn()} onExport={failingExport} onExportAll={vi.fn()} + onImport={vi.fn()} + onOpenJupyterLab={vi.fn()} /> ) }) @@ -195,6 +208,8 @@ describe('SessionNotebookContent export', () => { onClose={vi.fn()} onExport={vi.fn()} onExportAll={vi.fn()} + onImport={vi.fn()} + onOpenJupyterLab={vi.fn()} /> ) }) @@ -204,7 +219,10 @@ describe('SessionNotebookContent export', () => { it('invokes onExportAll for the "Download all" button on mixed sessions', async () => { const onExportAll = vi.fn().mockResolvedValue(undefined) - const mixedRuns: NotebookRunRecord[] = [run, { ...run, runId: 'r1', kernelKind: 'r', environment: 'default-r' }] + const mixedRuns: NotebookRunRecord[] = [ + run, + { ...run, runId: 'r1', kernelKind: 'r', environment: 'default-r' } + ] await act(async () => { root.render( { onClose={vi.fn()} onExport={vi.fn()} onExportAll={onExportAll} + onImport={vi.fn()} + onOpenJupyterLab={vi.fn()} /> ) }) @@ -241,6 +261,8 @@ describe('SessionNotebookContent export', () => { onClose={vi.fn()} onExport={vi.fn()} onExportAll={onExportAll} + onImport={vi.fn()} + onOpenJupyterLab={vi.fn()} /> ) }) @@ -251,4 +273,63 @@ describe('SessionNotebookContent export', () => { expect(allButton).toBeNull() expect(onExportAll).not.toHaveBeenCalled() }) + + it('invokes import for an empty notebook and surfaces failures', async () => { + const onImport = vi.fn().mockRejectedValue(new Error('Invalid notebook')) + await act(async () => { + root.render( + + ) + }) + + const button = container.querySelector('button[aria-label="Import .ipynb"]') + expect(button?.disabled).toBe(false) + await act(async () => { + button?.click() + await Promise.resolve() + }) + + expect(onImport).toHaveBeenCalledOnce() + expect(container.querySelector('[role="alert"]')?.textContent).toBe('Invalid notebook') + expect(button?.disabled).toBe(false) + }) + + it('opens JupyterLab and surfaces launcher failures', async () => { + const onOpenJupyterLab = vi.fn().mockRejectedValue(new Error('Install denied')) + await act(async () => { + root.render( + + ) + }) + + const button = container.querySelector( + 'button[aria-label="Open in JupyterLab"]' + ) + expect(button?.disabled).toBe(false) + await act(async () => { + button?.click() + await Promise.resolve() + }) + + expect(onOpenJupyterLab).toHaveBeenCalledOnce() + expect(container.querySelector('[role="alert"]')?.textContent).toBe('Install denied') + }) }) diff --git a/src/renderer/src/pages/workspace/SessionNotebookDialog.render.test.tsx b/src/renderer/src/pages/workspace/SessionNotebookDialog.render.test.tsx index 4f9980d1..e8987af1 100644 --- a/src/renderer/src/pages/workspace/SessionNotebookDialog.render.test.tsx +++ b/src/renderer/src/pages/workspace/SessionNotebookDialog.render.test.tsx @@ -31,6 +31,8 @@ const renderContent = (props: { onClose={vi.fn()} onExport={vi.fn()} onExportAll={vi.fn()} + onImport={vi.fn()} + onOpenJupyterLab={vi.fn()} {...props} /> ) @@ -81,6 +83,8 @@ describe('SessionNotebookContent', () => { )?.[0] expect(populatedButton).not.toMatch(/\sdisabled(?:=|\s|>)/) expect(emptyButton).toMatch(/\sdisabled(?:=|\s|>)/) + const emptyImportButton = empty.match(/]*aria-label="Import \.ipynb"[^>]*>/)?.[0] + expect(emptyImportButton).not.toMatch(/\sdisabled(?:=|\s|>)/) }) it('hides the "Download all" button when the session has only one data kernel', () => { diff --git a/src/renderer/src/pages/workspace/SessionNotebookDialog.tsx b/src/renderer/src/pages/workspace/SessionNotebookDialog.tsx index fcbac077..01c7bde7 100644 --- a/src/renderer/src/pages/workspace/SessionNotebookDialog.tsx +++ b/src/renderer/src/pages/workspace/SessionNotebookDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Download, LoaderCircle, X } from 'lucide-react' +import { Download, ExternalLink, LoaderCircle, Upload, X } from 'lucide-react' import { Dialog } from 'radix-ui' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' @@ -82,6 +82,8 @@ type SessionNotebookContentProps = { onClose: () => void onExport: (kernel: NotebookKernelKind) => Promise onExportAll: () => Promise + onImport: () => Promise + onOpenJupyterLab: () => Promise } // Pure presentational body of the dialog: header summary, empty/loading/error/populated states, @@ -94,13 +96,19 @@ const SessionNotebookContent = ({ error, onClose, onExport, - onExportAll + onExportAll, + onImport, + onOpenJupyterLab }: SessionNotebookContentProps): React.JSX.Element => { const [activeKind, setActiveKind] = useState('python') const [exporting, setExporting] = useState(false) const [exportingAll, setExportingAll] = useState(false) const [exportError, setExportError] = useState() const [exportSuccess, setExportSuccess] = useState() + const [importing, setImporting] = useState(false) + const [importError, setImportError] = useState() + const [openingJupyterLab, setOpeningJupyterLab] = useState(false) + const [jupyterLabError, setJupyterLabError] = useState() const shortId = sessionId.slice(0, 8) const agents = runs.some((run) => run.source === 'agent') ? 1 : 0 // Only python/r runs are "cells" in the notebook sense; repl/bash are control-plane/shell runs @@ -124,8 +132,9 @@ const SessionNotebookContent = ({ ? activeKind : (KERNEL_KIND_ORDER.find((kind) => kindsWithRuns.has(kind)) ?? visibleKinds[0] ?? 'python') const visibleRuns = runs.filter((run) => resolveRunKernelKind(run) === effectiveActiveKind) - const busy = exporting || exportingAll + const busy = exporting || exportingAll || importing || openingJupyterLab const exportDisabled = status !== 'ready' || runs.length === 0 || busy + const importDisabled = status !== 'ready' || busy // The main button's "current tab" = the kernel whose .ipynb will be saved. repl/bash tabs fold // into the most recent data kernel so the file still has a real kernelspec; sessions that never @@ -167,6 +176,30 @@ const SessionNotebookContent = ({ } } + const handleImport = async (): Promise => { + setImporting(true) + setImportError(undefined) + try { + await onImport() + } catch (importFailure) { + setImportError(getErrorMessage(importFailure)) + } finally { + setImporting(false) + } + } + + const handleOpenJupyterLab = async (): Promise => { + setOpeningJupyterLab(true) + setJupyterLabError(undefined) + try { + await onOpenJupyterLab() + } catch (openFailure) { + setJupyterLabError(getErrorMessage(openFailure)) + } finally { + setOpeningJupyterLab(false) + } + } + // The "Download all" path is only useful when there's more than one data kernel to write; a // single-kernel session's secondary button would just duplicate the main button. The data-kernel // count comes from `kindsWithRuns` (control-plane kinds don't generate their own .ipynb). @@ -254,13 +287,41 @@ const SessionNotebookContent = ({

- {exportError ?? exportSuccess} + {jupyterLabError ?? importError ?? exportError ?? exportSuccess}

+ + {/* Secondary action: only when there's more than one data kernel to write, otherwise it would just duplicate the main button. The "Download all (N)" label surfaces the count so the user knows how many files they're about to create. */} @@ -433,6 +494,24 @@ const SessionNotebookDialog = ({ } return undefined }} + onImport={async () => { + const request = { + sessionId: session.id, + projectName: session.projectId, + workspaceCwd: session.cwd ?? '' + } + const result = await window.api.notebook.importIpynb(request) + if (!result.imported) return + setRuns(await loadSessionNotebookRuns(window.api.notebook, request)) + setStatus('ready') + }} + onOpenJupyterLab={async () => { + await window.api.notebook.openInJupyterLab({ + sessionId: session.id, + projectName: session.projectId, + workspaceCwd: session.cwd ?? '' + }) + }} /> ) : null} diff --git a/src/renderer/web/api-map.generated.ts b/src/renderer/web/api-map.generated.ts index 45d311d9..2e54693b 100644 --- a/src/renderer/web/api-map.generated.ts +++ b/src/renderer/web/api-map.generated.ts @@ -53,6 +53,8 @@ export const WEB_INVOKE_CHANNELS = { 'notebook.exportIpynbAll': 'notebook:export-ipynb-all', 'notebook.finishCodeCell': 'notebook:finish-code-cell', 'notebook.getReference': 'notebook:reference', + 'notebook.importIpynb': 'notebook:import-ipynb', + 'notebook.openInJupyterLab': 'notebook:open-jupyterlab', 'notebook.restart': 'notebook:restart', 'notebook.runCell': 'notebook:run-cell', 'notebook.shutdown': 'notebook:shutdown', diff --git a/src/shared/notebook.ts b/src/shared/notebook.ts index bfa8bdb9..f41c726e 100644 --- a/src/shared/notebook.ts +++ b/src/shared/notebook.ts @@ -14,7 +14,14 @@ export type NotebookRunInputKind = 'cell' | 'terminal' // died (crash / force-quit) while the run was in flight — reconciled from a stale 'running' on the // next startup. 'cancelled' = the run was deliberately aborted (e.g. a force-stop disable). export type NotebookRunStatus = - 'queued' | 'running' | 'completed' | 'failed' | 'timeout' | 'interrupted' | 'cancelled' + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'timeout' + | 'interrupted' + | 'cancelled' + | 'imported' // Languages a notebook kernel can run in this phase; each runs as a persistent exec-loop process // (no ipykernel/IRkernel involved). @@ -269,6 +276,20 @@ export type ExportNotebookAllResult = files: Array<{ kernel: 'python' | 'r'; filePath: string }> } +export type ImportNotebookResult = + | { imported: false } + | { + imported: true + cellCount: number + skippedCellCount: number + } + +export type OpenJupyterLabResult = { + opened: true + url: string + alreadyRunning: boolean +} + // Starts a streamed code write into a notebook cell. export type BeginNotebookCodeCellRequest = NotebookSessionRequest & { cellId?: string