Files
orca/src/main/ipc/notebook.ts
T
NeilandOrca 46646d7ff1 chore(lint): upgrade oxlint to 1.71 + enable 7 new rules (autofixed backlog) (#6841)
* chore(lint): upgrade oxlint to 1.71 and enable 7 new rules

Upgrade oxlint 1.67.0 -> 1.71.0 (1.72 was blocked by the repo's 3-day
minimum-release-age supply-chain guard; nothing here needs it). The
bump is a no-op on the existing config.

Enable 3 error rules (backlog autofixed to zero in this commit) and
4 warn rules (surface signal without gating CI):

error (autofixed, behavior-preserving):
- unicorn/prefer-node-protocol        (~1531 sites: bare builtin -> node:)
- typescript/no-import-type-side-effects (~36: all-inline-type -> import type)
- unicorn/no-array-reverse            (19: copy-then-reverse -> toReversed)

warn (real signal, current fires are test-only/correct):
- unicorn/no-array-fill-with-reference-type  (aliasing footgun guard)
- typescript/no-unsafe-function-type         (bans bare Function type)
- unicorn/prefer-array-flat-map              (map().flat() -> flatMap())
- unicorn/prefer-regexp-test                 (.match() in bool ctx -> .test())

mobile/.oxlintrc.json extends root, so it inherits all 7; the autofix
ran from root and covered mobile/ too.

Verification (all green): oxlint 0 errors (root+mobile+aux configs),
oxfmt clean, typecheck (node+cli+web), vitest 22795 passed / 0 failed,
builds (electron-vite + web + cli) succeed. node: rewrites confirmed to
skip embedded SSH/CLI string payloads (AST-only); all toReversed sites
verified to operate on fresh copies or write-once locals.

* chore(lint): bump mobile oxlint to 1.71 so inherited rules parse

mobile/ is a standalone pnpm project pinning its own oxlint@1.67, which
lacks unicorn/no-array-fill-with-reference-type (needs >=1.70). Since
mobile/.oxlintrc.json extends the root config, mobile CI's 'cd mobile &&
oxlint' failed to parse the new rule. Bump mobile to match root (1.71).

Verified in mobile/: oxlint 0 errors, oxfmt --check clean, tsc --noEmit
pass, vitest 978 passed / 0 failed.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-06-29 22:38:29 -07:00

240 lines
6.9 KiB
TypeScript

import { spawn } from 'node:child_process'
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { dirname } from 'node:path'
import { ipcMain } from 'electron'
import type { Store } from '../persistence'
import { resolveAuthorizedPath } from './filesystem-auth'
export type NotebookRunResult = {
stdout: string
stderr: string
exitCode: number | null
error?: string
}
const PYTHON_RUN_TIMEOUT_MS = 60_000
const MAX_CAPTURE_BYTES = 2 * 1024 * 1024
type BoundedCapture = {
text: string
bytes: number
truncated: boolean
}
function pythonCandidates(): { command: string; argsPrefix: string[] }[] {
const configured = process.env.ORCA_NOTEBOOK_PYTHON?.trim()
const candidates: { command: string; argsPrefix: string[] }[] = []
if (configured) {
candidates.push({ command: configured, argsPrefix: [] })
}
if (process.platform === 'win32') {
candidates.push({ command: 'py', argsPrefix: ['-3'] })
}
candidates.push({ command: 'python3', argsPrefix: [] }, { command: 'python', argsPrefix: [] })
return candidates
}
function appendBounded(capture: BoundedCapture, chunk: Buffer): void {
if (capture.truncated) {
return
}
const remainingBytes = MAX_CAPTURE_BYTES - capture.bytes
if (remainingBytes <= 0) {
capture.truncated = true
return
}
if (chunk.byteLength <= remainingBytes) {
capture.text += chunk.toString('utf8')
capture.bytes += chunk.byteLength
return
}
capture.text += `${chunk.subarray(0, remainingBytes).toString('utf8')}\n[output truncated]\n`
capture.bytes = MAX_CAPTURE_BYTES
capture.truncated = true
}
function terminateNotebookProcessTree(
child: ChildProcessWithoutNullStreams
): ReturnType<typeof setTimeout> | null {
if (!child.pid) {
child.kill()
return null
}
if (process.platform === 'win32') {
try {
// Why: a timed-out cell can spawn descendants. taskkill /T is the
// Windows equivalent of terminating the whole process group.
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true
})
killer.on('error', () => child.kill())
killer.unref()
} catch {
child.kill()
}
return null
}
try {
process.kill(-child.pid, 'SIGTERM')
} catch {
child.kill()
}
const forceKillTimer = setTimeout(() => {
try {
process.kill(-child.pid!, 'SIGKILL')
} catch {
/* process group already exited */
}
}, 2000)
forceKillTimer.unref?.()
return forceKillTimer
}
function buildPythonExecutionCode(code: string, preamble: string): string {
const payload = Buffer.from(JSON.stringify({ code, preamble }), 'utf8').toString('base64')
return [
'import base64, contextlib, io, json, sys, traceback',
`payload = json.loads(base64.b64decode(${JSON.stringify(payload)}).decode("utf-8"))`,
'namespace = {"__name__": "__main__"}',
'try:',
' with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):',
' exec(payload["preamble"], namespace)',
' exec(payload["code"], namespace)',
'except Exception:',
' traceback.print_exc()',
' sys.exit(1)'
].join('\n')
}
async function runPythonCandidate(
candidate: { command: string; argsPrefix: string[] },
code: string,
preamble: string,
cwd: string
): Promise<NotebookRunResult> {
return new Promise((resolve) => {
const stdout: BoundedCapture = { text: '', bytes: 0, truncated: false }
const stderr: BoundedCapture = { text: '', bytes: 0, truncated: false }
let settled = false
let forceKillTimer: ReturnType<typeof setTimeout> | null = null
let timeout: ReturnType<typeof setTimeout> | null = null
const child = spawn(
candidate.command,
[...candidate.argsPrefix, '-c', buildPythonExecutionCode(code, preamble)],
{
cwd,
detached: process.platform !== 'win32',
windowsHide: true,
env: process.env
}
)
const cleanup = (options: { clearForceKillTimer: boolean }): void => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
if (forceKillTimer && options.clearForceKillTimer) {
clearTimeout(forceKillTimer)
forceKillTimer = null
}
child.stdout.off('data', onStdoutData)
child.stderr.off('data', onStderrData)
child.off('error', onError)
child.off('close', onClose)
}
const finish = (
result: NotebookRunResult,
options: { clearForceKillTimer: boolean } = { clearForceKillTimer: true }
): void => {
if (settled) {
return
}
settled = true
cleanup(options)
resolve(result)
}
timeout = setTimeout(() => {
forceKillTimer = terminateNotebookProcessTree(child)
finish(
{
stdout: stdout.text,
stderr: stderr.text,
exitCode: null,
error: 'Python cell timed out.'
},
{ clearForceKillTimer: false }
)
}, PYTHON_RUN_TIMEOUT_MS)
const onStdoutData = (chunk: Buffer): void => {
appendBounded(stdout, chunk)
}
const onStderrData = (chunk: Buffer): void => {
appendBounded(stderr, chunk)
}
const onError = (error: Error): void => {
finish({ stdout: stdout.text, stderr: stderr.text, exitCode: null, error: error.message })
}
const onClose = (exitCode: number | null): void => {
finish({
stdout: stdout.text,
stderr: stderr.text,
exitCode
})
}
child.stdout.on('data', onStdoutData)
child.stderr.on('data', onStderrData)
child.on('error', onError)
child.on('close', onClose)
})
}
async function runPythonCell(
code: string,
preamble: string,
cwd: string
): Promise<NotebookRunResult> {
if (!code.trim() && !preamble.trim()) {
return { stdout: '', stderr: '', exitCode: 0 }
}
let lastError = 'Python was not found.'
for (const candidate of pythonCandidates()) {
const result = await runPythonCandidate(candidate, code, preamble, cwd)
if (!result.error?.includes('ENOENT')) {
return result
}
lastError = result.error
}
return { stdout: '', stderr: '', exitCode: null, error: lastError }
}
export function registerNotebookHandlers(store: Store): void {
ipcMain.handle(
'notebook:runPythonCell',
async (
_event,
args: { filePath: string; code: string; preamble?: string; connectionId?: string | null }
): Promise<NotebookRunResult> => {
if (args.connectionId) {
return {
stdout: '',
stderr: '',
exitCode: null,
error: 'Notebook execution is currently supported for local files only.'
}
}
const filePath = await resolveAuthorizedPath(args.filePath, store)
// Why: execute relative to the notebook file so local imports and data
// paths behave the same way users expect from a notebook opened on disk.
return runPythonCell(args.code, args.preamble ?? '', dirname(filePath))
}
)
}