mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
* 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>
240 lines
6.9 KiB
TypeScript
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))
|
|
}
|
|
)
|
|
}
|