mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 16:02:42 +00:00
* feat(ipynb): run notebook cells in a persistent Jupyter kernel Replaces the fresh-process runner (which silently re-ran every earlier cell) with the user's own ipykernel, driven by a small bundled Python bridge over line-framed JSON. One kernel per open notebook: started on first Run, shut down when its tab closes or Orca exits (stdin EOF), and the kernel's own parent poller reaps it if the bridge dies. The header gains a kernel pill (workspace .venv/.conda recommended, PATH interpreters, Browse), Interrupt/Restart/Run all/Clear all, and a one-time missing-ipykernel dialog with Install. Outputs stream live per cell and are written into the document when the run finishes. * test(ipynb): cover the stalled-interrupt restart offer * refactor(ipynb): disable the kernel pill while settling; merge its classes with cn * refactor(ipynb): tie kernels to their renderer document and simplify the run flow - Main keys kernels per renderer, so a reload, renderer crash or closed window shuts them down, and two windows never share one notebook kernel. One close-driven cleanup replaces the separate exit and start-failure deletions. - The first run uses the nearest workspace env, else the first Python on PATH; the picker no longer opens itself, so its open state stays in the toolbar. Closing the picker brings the missing-ipykernel dialog back instead of dropping the queue, which also keeps a Browse pick's run. - Discovery marks the kernel starting, so a second run during it queues instead of starting a second kernel, and a tab closed mid-discovery no longer leaks one. - Running an nbformat 4.4 notebook gives its cells ids (upgrading to 4.5), so moving a cell mid-run cannot misroute its output. - The death notice drops stderr from before the kernel was ready (the unencrypted-TCP warning). - SSH and non-Python runs toast instead of writing a notice into the cell. - Windows conda envs are named after their folder. * fix(ipynb): install ipykernel into envs without pip uv-created venvs ship without pip, so Install failed with 'No module named pip' there. When pip is missing, bootstrap it with the stdlib's ensurepip and retry. The install moves beside the other interpreter probes, and the copyable install command comes from one helper. * fix(ipynb): address PR review comments on stream errors, old jupyter_client and the Windows venv hint - Swallow stdout/stderr stream errors on the bridge child, as spawnProcess requires, so a broken pipe cannot crash main. - The bridge exits (reporting the death) even when cleanup_resources is missing (jupyter_client < 6.1.5) or raises. - The install-failure hint suggests `py -m venv .venv` on Windows. * feat(ipynb): add Cancel to the missing-ipykernel dialog It does what Esc does: drops the cells waiting on the kernel. * fix(ipynb): recover from a rejected kernel start; quote the install command per shell - Discovery moves into start, so one catch turns a rejected listPythonEnvironments or startKernel into the usual failed start: the session returns to off with the error in the cell, instead of sticking at starting. - The copyable ipykernel command quotes the interpreter only when its path has whitespace, prefixing PowerShell's call operator on Windows. Install itself still spawns without a shell. * fix(ipynb): always shell-quote the copyable ipykernel install command Quote the interpreter path for every path, not only ones with whitespace, so paths with shell metacharacters like & copy as a working command. Single quotes are literal in POSIX shells and PowerShell; embedded quotes are escaped per shell, and PowerShell keeps its & call operator.
115 lines
4.4 KiB
TypeScript
115 lines
4.4 KiB
TypeScript
import { existsSync } from 'node:fs'
|
|
import { basename, dirname, isAbsolute, join, relative } from 'node:path'
|
|
import { runProcess } from '../../shared/child-process/run-process'
|
|
import type { PythonEnvironment, PythonEnvironments } from '../../shared/notebook-kernel-types'
|
|
|
|
const PROBE = 'import sys, platform; print(sys.executable); print(platform.python_version())'
|
|
const PROBE_TIMEOUT_MS = 10_000
|
|
const WORKSPACE_ENV_DIRS = ['.venv', '.conda']
|
|
const INSTALL_TIMEOUT_MS = 10 * 60_000
|
|
const INSTALL_DETAIL_CHARS = 4000
|
|
|
|
/** `.venv`/`.conda` interpreters from the notebook's folder up to the workspace root, nearest first. */
|
|
export function findWorkspaceInterpreters(
|
|
notebookPath: string,
|
|
rootPath: string | null,
|
|
platform: NodeJS.Platform = process.platform,
|
|
exists: (path: string) => boolean = existsSync
|
|
): string[] {
|
|
const interpreters: string[] = []
|
|
for (let dir = dirname(notebookPath); ; dir = dirname(dir)) {
|
|
for (const envDir of WORKSPACE_ENV_DIRS.map((name) => join(dir, name))) {
|
|
// Windows venvs keep python.exe in Scripts\, conda envs at the env root.
|
|
const candidates =
|
|
platform === 'win32'
|
|
? [join(envDir, 'Scripts', 'python.exe'), join(envDir, 'python.exe')]
|
|
: [join(envDir, 'bin', 'python')]
|
|
const interpreter = candidates.find(exists)
|
|
if (interpreter) {
|
|
interpreters.push(interpreter)
|
|
}
|
|
}
|
|
const fromRoot = rootPath === null ? '' : relative(rootPath, dir)
|
|
if (!fromRoot || fromRoot.startsWith('..') || isAbsolute(fromRoot) || dirname(dir) === dir) {
|
|
return interpreters
|
|
}
|
|
}
|
|
}
|
|
|
|
async function probe(
|
|
program: string,
|
|
args: string[],
|
|
name: (executable: string) => string
|
|
): Promise<PythonEnvironment | null> {
|
|
try {
|
|
const result = await runProcess({
|
|
program,
|
|
args: [...args, '-c', PROBE],
|
|
timeoutMs: PROBE_TIMEOUT_MS
|
|
})
|
|
const [executable, version] = result.stdout.trim().split(/\r?\n/)
|
|
return result.code === 0 && executable && version
|
|
? { path: executable, name: name(executable), version }
|
|
: null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Names an interpreter after its environment folder when it lives in one. */
|
|
function environmentName(executable: string): string {
|
|
// venvs keep python in bin/ or Scripts\; Windows conda envs keep it at the env root.
|
|
const envDir = [dirname(dirname(executable)), dirname(executable)].find(
|
|
(dir) => existsSync(join(dir, 'pyvenv.cfg')) || existsSync(join(dir, 'conda-meta'))
|
|
)
|
|
return basename(envDir ?? executable)
|
|
}
|
|
|
|
export function describePython(path: string): Promise<PythonEnvironment | null> {
|
|
return probe(path, [], environmentName)
|
|
}
|
|
|
|
export async function listPythonEnvironments(
|
|
notebookPath: string,
|
|
rootPath: string | null
|
|
): Promise<PythonEnvironments> {
|
|
const pathCommands =
|
|
process.platform === 'win32' ? [['py', '-3'], ['python']] : [['python3'], ['python']]
|
|
const [workspace, onPath] = await Promise.all([
|
|
Promise.all(findWorkspaceInterpreters(notebookPath, rootPath).map(describePython)),
|
|
Promise.all(
|
|
pathCommands.map(([program, ...args]) =>
|
|
probe(program, args, () => [program, ...args].join(' '))
|
|
)
|
|
)
|
|
])
|
|
const seen = new Set<string>()
|
|
const unique = (environments: (PythonEnvironment | null)[]): PythonEnvironment[] =>
|
|
environments.filter((env): env is PythonEnvironment => {
|
|
if (!env || seen.has(env.path)) {
|
|
return false
|
|
}
|
|
seen.add(env.path)
|
|
return true
|
|
})
|
|
return { workspace: unique(workspace), path: unique(onPath) }
|
|
}
|
|
|
|
/** `pip install -U ipykernel` into the interpreter's environment, bootstrapping pip if it has none. */
|
|
export async function installIpykernel(python: string): Promise<{ ok: boolean; detail: string }> {
|
|
const run = (args: string[]) =>
|
|
runProcess({ program: python, args: ['-m', ...args], timeoutMs: INSTALL_TIMEOUT_MS })
|
|
try {
|
|
let result = await run(['pip', 'install', '-U', 'ipykernel'])
|
|
// Why: uv-created venvs ship without pip; the stdlib's ensurepip bootstraps it.
|
|
if (result.code !== 0 && result.stderr.includes('No module named pip')) {
|
|
await run(['ensurepip'])
|
|
result = await run(['pip', 'install', '-U', 'ipykernel'])
|
|
}
|
|
const detail = (result.stderr.trim() || result.stdout.trim()).slice(-INSTALL_DETAIL_CHARS)
|
|
return { ok: result.code === 0, detail }
|
|
} catch (error) {
|
|
return { ok: false, detail: error instanceof Error ? error.message : String(error) }
|
|
}
|
|
}
|