@lincyaw/addon-exec 0.0.0-stage → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/LICENSES +696 -0
- package/README.md +33 -2
- package/components/PmRunPanel.css +167 -0
- package/components/PmRunPanel.tsx +389 -0
- package/components/PmStepRun.css +61 -0
- package/components/PmStepRun.tsx +202 -0
- package/package.json +50 -3
- package/src/client/RunOutputs.css +21 -0
- package/src/client/RunOutputs.tsx +69 -0
- package/src/client/StepMark.css +3 -0
- package/src/client/StepMark.tsx +12 -0
- package/src/client/env.d.ts +13 -0
- package/src/client/feedback.ts +69 -0
- package/src/client/format.ts +30 -0
- package/src/client/index.ts +14 -0
- package/src/client/messages.ts +102 -0
- package/src/client/outputs.ts +29 -0
- package/src/client/plugin.ts +55 -0
- package/src/client/pyodide/executor.ts +94 -0
- package/src/client/pyodide/presmium_gpu.py +297 -0
- package/src/client/pyodide/protocol.ts +27 -0
- package/src/client/pyodide/pyodide.worker.ts +68 -0
- package/src/client/pyodide/run_await.py +57 -0
- package/src/client/runs-in.ts +27 -0
- package/src/client/script/executor.ts +62 -0
- package/src/client/script/script.worker.ts +9 -0
- package/src/client/slide-fit.ts +61 -0
- package/src/client/step-marks.ts +7 -0
- package/src/client/when-service.ts +14 -0
- package/src/domain/browser-python.ts +33 -0
- package/src/domain/config.ts +41 -0
- package/src/domain/index.ts +11 -0
- package/src/domain/outputs.ts +18 -0
- package/src/domain/router.ts +50 -0
- package/src/domain/runs.ts +72 -0
- package/src/domain/static-output.ts +20 -0
- package/src/domain/trace.ts +32 -0
- package/src/node/browser-imports.ts +34 -0
- package/src/node/child-process.ts +109 -0
- package/src/node/code-component.ts +48 -0
- package/src/node/notebook-cells.ts +46 -0
- package/src/node/presmium.ts +27 -0
- package/src/node/pyodide.ts +22 -0
- package/src/node/python.ts +40 -0
- package/src/node/run-panel.ts +71 -0
- package/src/node/script-main.mjs +11 -0
- package/src/node/script.ts +26 -0
- package/src/node/step-run.ts +71 -0
- package/src/node/timeouts.ts +15 -0
- package/src/shared/script-runner.d.mts +16 -0
- package/src/shared/script-runner.mjs +168 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { siteBase } from '@lincyaw/host'
|
|
2
|
+
import { failedRun } from '@lincyaw/kernel'
|
|
3
|
+
import type { Executor, RunResult } from '@lincyaw/kernel'
|
|
4
|
+
import { isScript, isTypeScript, SCRIPT_IMPORTS } from '../../domain/index.ts'
|
|
5
|
+
import type { ScriptOutput, ScriptRequest } from '../../shared/script-runner.mjs'
|
|
6
|
+
import { messages } from '../messages.ts'
|
|
7
|
+
|
|
8
|
+
export const BROWSER_SCRIPT_EXECUTOR_ID = 'js-worker'
|
|
9
|
+
|
|
10
|
+
export type ScriptExecutor = Executor & { dispose(): void }
|
|
11
|
+
|
|
12
|
+
function imports(): Record<string, string> {
|
|
13
|
+
return Object.fromEntries(Object.entries(SCRIPT_IMPORTS).map(([name, path]) => [name, new URL(`${siteBase()}${path}`, location.href).href]))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createScriptExecutor(options: { timeoutMs: number }): ScriptExecutor {
|
|
17
|
+
const id = BROWSER_SCRIPT_EXECUTOR_ID
|
|
18
|
+
const active = new Set<() => void>()
|
|
19
|
+
let disposed = false
|
|
20
|
+
return {
|
|
21
|
+
id,
|
|
22
|
+
runsIn: 'browser',
|
|
23
|
+
supports: isScript,
|
|
24
|
+
run(request, signal) {
|
|
25
|
+
return new Promise<RunResult>((resolve) => {
|
|
26
|
+
if (disposed || signal?.aborted) return resolve(failedRun(id, messages().cancelled))
|
|
27
|
+
const worker = new Worker(new URL('./script.worker.ts', import.meta.url), { type: 'module', name: 'presmium-js' })
|
|
28
|
+
const started = performance.now()
|
|
29
|
+
const elapsed = () => Math.round(performance.now() - started)
|
|
30
|
+
const timeoutMs = request.timeoutMs ?? options.timeoutMs
|
|
31
|
+
let finished = false
|
|
32
|
+
const finish = (result: RunResult) => {
|
|
33
|
+
if (finished) return
|
|
34
|
+
finished = true
|
|
35
|
+
active.delete(onAbort)
|
|
36
|
+
clearTimeout(timer)
|
|
37
|
+
worker.onmessage = null
|
|
38
|
+
worker.onerror = null
|
|
39
|
+
worker.terminate()
|
|
40
|
+
signal?.removeEventListener('abort', onAbort)
|
|
41
|
+
resolve(result)
|
|
42
|
+
}
|
|
43
|
+
const onAbort = () => finish(failedRun(id, messages().cancelled, elapsed()))
|
|
44
|
+
const timer = setTimeout(() => finish({ ...failedRun(id, messages().runTimeout(timeoutMs), elapsed()), timedOut: true }), timeoutMs)
|
|
45
|
+
active.add(onAbort)
|
|
46
|
+
worker.onmessage = ({ data }: MessageEvent<ScriptOutput>) => finish({
|
|
47
|
+
ok: data.exitCode === 0, ...data, durationMs: elapsed(), timedOut: false, source: 'live', executorId: id,
|
|
48
|
+
})
|
|
49
|
+
worker.onerror = event => finish(failedRun(id, messages().runtimeCrashed('JavaScript', event.message), elapsed()))
|
|
50
|
+
signal?.addEventListener('abort', onAbort)
|
|
51
|
+
const message: ScriptRequest = { code: request.code, args: { ...request.args }, typescript: isTypeScript(request.lang), imports: imports() }
|
|
52
|
+
worker.postMessage(message)
|
|
53
|
+
})
|
|
54
|
+
},
|
|
55
|
+
dispose() {
|
|
56
|
+
if (disposed) return
|
|
57
|
+
disposed = true
|
|
58
|
+
for (const cancel of [...active]) cancel()
|
|
59
|
+
active.clear()
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/// <reference lib="webworker" />
|
|
2
|
+
import { runScript } from '../../shared/script-runner.mjs'
|
|
3
|
+
import type { ScriptRequest } from '../../shared/script-runner.mjs'
|
|
4
|
+
|
|
5
|
+
declare const self: DedicatedWorkerGlobalScope
|
|
6
|
+
|
|
7
|
+
self.onmessage = async ({ data }: MessageEvent<ScriptRequest>) => {
|
|
8
|
+
self.postMessage(await runScript(data))
|
|
9
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef } from 'react'
|
|
2
|
+
import type { RefObject } from 'react'
|
|
3
|
+
|
|
4
|
+
const px = (value: string) => Number.parseFloat(value) || 0
|
|
5
|
+
|
|
6
|
+
function slideOf(el: HTMLElement): HTMLElement | undefined {
|
|
7
|
+
const path: HTMLElement[] = []
|
|
8
|
+
for (let box = el.parentElement; box; box = box.parentElement) {
|
|
9
|
+
path.push(box)
|
|
10
|
+
if (getComputedStyle(box).overflowY !== 'hidden') continue
|
|
11
|
+
const { top, bottom } = box.getBoundingClientRect()
|
|
12
|
+
return path.find((inner) => {
|
|
13
|
+
const covers = inner.getBoundingClientRect()
|
|
14
|
+
return Math.abs(covers.top - top) < 1 && Math.abs(covers.bottom - bottom) < 1
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function layoutTop(el: HTMLElement): number {
|
|
20
|
+
let top = el.offsetTop
|
|
21
|
+
for (let box = el.offsetParent; box instanceof HTMLElement; box = box.offsetParent) top += box.clientTop + box.offsetTop
|
|
22
|
+
return top
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function spaceBelow(el: HTMLElement): number | undefined {
|
|
26
|
+
const slide = el.offsetParent ? slideOf(el) : undefined
|
|
27
|
+
if (!slide?.clientHeight) return undefined
|
|
28
|
+
let below = px(getComputedStyle(el).marginBottom)
|
|
29
|
+
for (let box = el.parentElement; box && box !== slide; box = box.parentElement) {
|
|
30
|
+
const style = getComputedStyle(box)
|
|
31
|
+
below += px(style.paddingBottom) + px(style.borderBottomWidth)
|
|
32
|
+
}
|
|
33
|
+
const top = layoutTop(el) - layoutTop(slide) - slide.clientTop
|
|
34
|
+
return Math.max(0, slide.clientHeight - px(getComputedStyle(slide).paddingBottom) - top - below)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Calls `fit` with the height left below the element on its slide, again whenever the layout around it changes. */
|
|
38
|
+
export function useSlideSpaceBelow(el: RefObject<HTMLElement | null | undefined>, active: boolean, fit: (space: number) => void): void {
|
|
39
|
+
const callback = useRef(fit)
|
|
40
|
+
callback.current = fit
|
|
41
|
+
useLayoutEffect(() => {
|
|
42
|
+
const target = el.current
|
|
43
|
+
if (!active || !target) return
|
|
44
|
+
let disposed = false
|
|
45
|
+
const measure = () => {
|
|
46
|
+
if (disposed) return
|
|
47
|
+
const space = spaceBelow(target)
|
|
48
|
+
if (space !== undefined) callback.current(space)
|
|
49
|
+
}
|
|
50
|
+
const observer = new ResizeObserver(measure)
|
|
51
|
+
observer.observe(target)
|
|
52
|
+
for (let box = target; box.parentElement; box = box.parentElement) {
|
|
53
|
+
for (let before = box.previousElementSibling; before; before = before.previousElementSibling) observer.observe(before)
|
|
54
|
+
observer.observe(box.parentElement)
|
|
55
|
+
if (getComputedStyle(box.parentElement).overflowY === 'hidden') break
|
|
56
|
+
}
|
|
57
|
+
measure()
|
|
58
|
+
void document.fonts?.ready.then(measure)
|
|
59
|
+
return () => { disposed = true; observer.disconnect() }
|
|
60
|
+
}, [el, active])
|
|
61
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createContext } from 'react'
|
|
2
|
+
import type { StepState } from '@lincyaw/kernel'
|
|
3
|
+
export function markLine(container: HTMLElement | null | undefined, line: number | undefined, className: string): void {
|
|
4
|
+
container?.querySelectorAll('code .line').forEach((el, i) => el.classList.toggle(className, i + 1 === line))
|
|
5
|
+
}
|
|
6
|
+
export type RegisterStepMark = (step: number, state: StepState) => () => void
|
|
7
|
+
export const STEP_MARKS = createContext<RegisterStepMark | undefined>(undefined)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { presmiumReport, presmiumService, runtimeRevision } from '@lincyaw/host'
|
|
2
|
+
import type { ServiceToken } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
export function whenService<T>(token: ServiceToken<T>): Promise<T | undefined> {
|
|
5
|
+
const initial = presmiumService(token)
|
|
6
|
+
if (initial || presmiumReport()) return Promise.resolve(initial)
|
|
7
|
+
return new Promise(resolve => {
|
|
8
|
+
const stop = runtimeRevision.subscribe(() => {
|
|
9
|
+
const service = presmiumService(token)
|
|
10
|
+
if (!service && !presmiumReport()) return
|
|
11
|
+
stop(); resolve(service)
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const STDLIB = new Set(`abc annotationlib antigravity argparse array ast asyncio atexit base64 bdb binascii bisect builtins bz2
|
|
2
|
+
cProfile calendar cmath cmd code codecs codeop collections colorsys compileall compression concurrent configparser
|
|
3
|
+
contextlib contextvars copy copyreg csv ctypes curses dataclasses datetime dbm decimal difflib dis doctest email
|
|
4
|
+
encodings ensurepip enum errno faulthandler fcntl filecmp fileinput fnmatch fractions ftplib functools gc genericpath
|
|
5
|
+
getopt getpass gettext glob graphlib grp gzip hashlib heapq hmac html http idlelib imaplib importlib inspect io
|
|
6
|
+
ipaddress itertools json keyword linecache locale logging lzma mailbox marshal math mimetypes mmap modulefinder msvcrt
|
|
7
|
+
multiprocessing netrc nt ntpath nturl2path numbers opcode operator optparse os pathlib pdb pickle pickletools pkgutil
|
|
8
|
+
platform plistlib poplib posix posixpath pprint profile pstats pty pwd py_compile pyclbr pydoc pydoc_data pyexpat queue
|
|
9
|
+
quopri random re readline reprlib resource rlcompleter runpy sched secrets select selectors shelve shlex shutil signal
|
|
10
|
+
site smtplib socket socketserver sqlite3 sre_compile sre_constants sre_parse ssl stat statistics string stringprep
|
|
11
|
+
struct subprocess symtable sys sysconfig syslog tabnanny tarfile tempfile termios textwrap this threading time timeit
|
|
12
|
+
tkinter token tokenize tomllib trace traceback tracemalloc tty turtle turtledemo types typing unicodedata unittest
|
|
13
|
+
urllib uuid venv warnings wave weakref webbrowser winreg winsound wsgiref xml xmlrpc zipapp zipfile zipimport zlib
|
|
14
|
+
zoneinfo`.split(/\s+/))
|
|
15
|
+
|
|
16
|
+
const BROWSER = new Set(['pyodide', 'pyodide_js', 'js', 'IPython', 'presmium_gpu'])
|
|
17
|
+
|
|
18
|
+
/** Whether Python in the browser (Pyodide, without packages) can import the top-level module. */
|
|
19
|
+
export function importableInBrowser(module: string): boolean {
|
|
20
|
+
return module.startsWith('_') || STDLIB.has(module) || BROWSER.has(module)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function pythonImports(code: string): string[] {
|
|
24
|
+
const names: string[] = []
|
|
25
|
+
for (const [, from, list] of code.matchAll(/^[ \t]*(?:from[ \t]+([A-Za-z_][\w.]*)[ \t]+import\b|import[ \t]+([^#;\n]+))/gm)) {
|
|
26
|
+
const modules = from ? [from] : list!.split(',').map(part => part.trim().split(/[ \t]+/)[0]!)
|
|
27
|
+
for (const module of modules) {
|
|
28
|
+
const name = module.split('.')[0]!
|
|
29
|
+
if (/^[A-Za-z_]\w*$/.test(name) && !names.includes(name)) names.push(name)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return names
|
|
33
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { languageId } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
export const EXEC_ID = 'presmium.exec'
|
|
4
|
+
|
|
5
|
+
export interface ExecConfig {
|
|
6
|
+
timeoutMs: number
|
|
7
|
+
loadTimeoutMs: number
|
|
8
|
+
maxTraceEvents: number
|
|
9
|
+
pyodideURL?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_EXEC_CONFIG: ExecConfig = { timeoutMs: 10_000, loadTimeoutMs: 30_000, maxTraceEvents: 500 }
|
|
13
|
+
|
|
14
|
+
const positive = (value: unknown, fallback: number) => (typeof value === 'number' && value > 0 ? value : fallback)
|
|
15
|
+
|
|
16
|
+
export function execConfig(raw: Record<string, unknown>): ExecConfig {
|
|
17
|
+
return {
|
|
18
|
+
timeoutMs: positive(raw.timeoutMs, DEFAULT_EXEC_CONFIG.timeoutMs),
|
|
19
|
+
loadTimeoutMs: positive(raw.loadTimeoutMs, DEFAULT_EXEC_CONFIG.loadTimeoutMs),
|
|
20
|
+
maxTraceEvents: positive(raw.maxTraceEvents, DEFAULT_EXEC_CONFIG.maxTraceEvents),
|
|
21
|
+
pyodideURL: typeof raw.pyodideURL === 'string' && raw.pyodideURL ? raw.pyodideURL : undefined,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function configuredTimeout(raw: Record<string, unknown>): number | undefined {
|
|
26
|
+
return typeof raw.timeoutMs === 'number' && raw.timeoutMs > 0 ? raw.timeoutMs : undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isPython(lang: string): boolean {
|
|
30
|
+
return languageId(lang) === 'python'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isScript(lang: string): boolean {
|
|
34
|
+
return ['javascript', 'typescript'].includes(languageId(lang))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const SCRIPT_IMPORTS: Readonly<Record<string, string>> = { '@jax-js/jax': 'presmium/jax-js/index.js' }
|
|
38
|
+
|
|
39
|
+
export function isTypeScript(lang: string): boolean {
|
|
40
|
+
return languageId(lang) === 'typescript'
|
|
41
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { configuredTimeout, DEFAULT_EXEC_CONFIG, EXEC_ID, execConfig, isPython, isScript, isTypeScript, SCRIPT_IMPORTS } from './config.ts'
|
|
2
|
+
export type { ExecConfig } from './config.ts'
|
|
3
|
+
export { createExecutorRouter } from './router.ts'
|
|
4
|
+
export type { RouterText } from './router.ts'
|
|
5
|
+
export { mimeText, outputText } from './outputs.ts'
|
|
6
|
+
export { appendRun, compareRuns, defaultPair, fallbackReason, stringArgs, withRecordedFallback } from './runs.ts'
|
|
7
|
+
export type { ArgChange, OutputLine, RecordedOutput, RunComparison, RunHistory, RunRecord } from './runs.ts'
|
|
8
|
+
export { importableInBrowser, pythonImports } from './browser-python.ts'
|
|
9
|
+
export { paramsPrelude, shownOutput } from './static-output.ts'
|
|
10
|
+
export { frameAt, TRACER } from './trace.ts'
|
|
11
|
+
export type { Trace, TraceEvent, TraceFrame, Tracer } from './trace.ts'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { RunResult } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
/** The text of a value of a MIME bundle: a list of lines (as nbformat splits long text) joined, other JSON printed. */
|
|
4
|
+
export function mimeText(value: unknown, space?: number): string {
|
|
5
|
+
return Array.isArray(value) ? value.join('') : typeof value === 'string' ? value : value === undefined ? '' : JSON.stringify(value, null, space)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** A readable projection of ordered notebook outputs, including the final expression. */
|
|
9
|
+
export function outputText(result: RunResult): string {
|
|
10
|
+
if (!result.outputs || result.source === 'recorded') return result.stdout + (result.source === 'live' ? result.stderr : '')
|
|
11
|
+
return result.outputs.map((output) => {
|
|
12
|
+
if (output.output_type === 'stream') return output.text
|
|
13
|
+
if (output.output_type === 'error') return `${output.traceback.join('\n') || `${output.ename}: ${output.evalue}`}\n`
|
|
14
|
+
const data = output.data
|
|
15
|
+
const text = mimeText(data['text/plain'] ?? data['text/markdown'] ?? data['application/json'], 2)
|
|
16
|
+
return text && !text.endsWith('\n') ? `${text}\n` : text
|
|
17
|
+
}).join('')
|
|
18
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { errorMessage, failedRun } from '@lincyaw/kernel'
|
|
2
|
+
import type { Executor, ExecutorRouter, RunRequest, RunResult } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
interface Entry {
|
|
5
|
+
executor: Executor
|
|
6
|
+
priority: number
|
|
7
|
+
order: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RouterText {
|
|
11
|
+
noExecutor(lang: string): string
|
|
12
|
+
executorUnavailable(executor: string, lang: string): string
|
|
13
|
+
executorCrashed(executor: string, error: string): string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createExecutorRouter(text: RouterText, id = 'presmium.exec.router'): ExecutorRouter {
|
|
17
|
+
let entries: Entry[] = []
|
|
18
|
+
let registrations = 0
|
|
19
|
+
const page = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
|
20
|
+
const scoped = (session: string) => `${session}@${page}`
|
|
21
|
+
const pick = (lang: string) => entries.find(entry => entry.executor.supports(lang))?.executor
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
id,
|
|
25
|
+
supports: lang => pick(lang) !== undefined,
|
|
26
|
+
async run(request: RunRequest, signal?: AbortSignal): Promise<RunResult> {
|
|
27
|
+
const named = request.executor
|
|
28
|
+
const executor = named ? entries.find(entry => entry.executor.id === named)?.executor : pick(request.lang)
|
|
29
|
+
if (named && !executor?.supports(request.lang)) return failedRun(id, text.executorUnavailable(named, request.lang))
|
|
30
|
+
if (!executor) return failedRun(id, text.noExecutor(request.lang))
|
|
31
|
+
const started = Date.now()
|
|
32
|
+
try {
|
|
33
|
+
return await executor.run(request.session ? { ...request, session: scoped(request.session) } : request, signal)
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return failedRun(executor.id, text.executorCrashed(executor.id, errorMessage(error)), Date.now() - started)
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
async endSession(session) {
|
|
39
|
+
await Promise.all(entries.map(entry => entry.executor.endSession?.(scoped(session)).catch(() => {})))
|
|
40
|
+
},
|
|
41
|
+
register(executor, priority = 0) {
|
|
42
|
+
const entry = { executor, priority, order: registrations++ }
|
|
43
|
+
entries = [...entries, entry].sort((a, b) => b.priority - a.priority || a.order - b.order)
|
|
44
|
+
return () => {
|
|
45
|
+
entries = entries.filter(e => e !== entry)
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
list: () => entries.map(entry => entry.executor),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { RunResult } from '@lincyaw/kernel'
|
|
2
|
+
import { diffLines } from 'diff'
|
|
3
|
+
import { outputText } from './outputs.ts'
|
|
4
|
+
|
|
5
|
+
export function stringArgs(params: Record<string, unknown> | undefined): Record<string, string> {
|
|
6
|
+
return Object.fromEntries(Object.entries(params ?? {}).map(([name, value]) => [name, value !== null && typeof value === 'object' ? JSON.stringify(value) : String(value)]))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type RecordedOutput = string
|
|
10
|
+
|
|
11
|
+
export function withRecordedFallback(result: RunResult, recorded: RecordedOutput | undefined): RunResult {
|
|
12
|
+
if (result.ok || recorded === undefined) return result
|
|
13
|
+
return { ...result, stdout: recorded, source: 'recorded' }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function fallbackReason(result: RunResult): 'timedOut' | 'failed' | undefined {
|
|
17
|
+
if (result.source !== 'recorded') return undefined
|
|
18
|
+
return result.timedOut ? 'timedOut' : 'failed'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RunRecord {
|
|
22
|
+
id: number
|
|
23
|
+
args: Record<string, string>
|
|
24
|
+
result: RunResult
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type RunHistory = readonly RunRecord[]
|
|
28
|
+
|
|
29
|
+
export function appendRun(history: RunHistory, args: Record<string, string>, result: RunResult): RunHistory {
|
|
30
|
+
return [...history, { id: history.length + 1, args: { ...args }, result }]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function defaultPair(history: RunHistory): [number, number] | undefined {
|
|
34
|
+
const n = history.length
|
|
35
|
+
return n >= 2 ? [history[n - 2]!.id, history[n - 1]!.id] : undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ArgChange {
|
|
39
|
+
name: string
|
|
40
|
+
before?: string
|
|
41
|
+
after?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface OutputLine {
|
|
45
|
+
kind: 'same' | 'added' | 'removed'
|
|
46
|
+
text: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RunComparison {
|
|
50
|
+
before: RunRecord
|
|
51
|
+
after: RunRecord
|
|
52
|
+
args: ArgChange[]
|
|
53
|
+
output: OutputLine[]
|
|
54
|
+
changed: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function compareRuns(before: RunRecord, after: RunRecord): RunComparison {
|
|
58
|
+
const names = [...new Set([...Object.keys(before.args), ...Object.keys(after.args)])]
|
|
59
|
+
const args = names
|
|
60
|
+
.filter(name => before.args[name] !== after.args[name])
|
|
61
|
+
.map(name => ({ name, before: before.args[name], after: after.args[name] }))
|
|
62
|
+
const output = diffLines(outputText(before.result), outputText(after.result)).flatMap(change =>
|
|
63
|
+
splitLines(change.value).map((text): OutputLine => ({ kind: change.added ? 'added' : change.removed ? 'removed' : 'same', text })),
|
|
64
|
+
)
|
|
65
|
+
return { before, after, args, output, changed: output.some(line => line.kind !== 'same') }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function splitLines(value: string): string[] {
|
|
69
|
+
const lines = value.split('\n')
|
|
70
|
+
if (lines.at(-1) === '') lines.pop()
|
|
71
|
+
return lines
|
|
72
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RunResult } from '@lincyaw/kernel'
|
|
2
|
+
import { isPython, isScript } from './config.ts'
|
|
3
|
+
import { outputText } from './outputs.ts'
|
|
4
|
+
|
|
5
|
+
function literal(value: unknown, script: boolean): string {
|
|
6
|
+
if (typeof value === 'boolean') return script ? String(value) : value ? 'True' : 'False'
|
|
7
|
+
return typeof value === 'number' ? String(value) : JSON.stringify(String(value))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Assignments of a panel's parameters, put before its code where the code is shown without running it (fallback, notebook). */
|
|
11
|
+
export function paramsPrelude(lang: string, params: Record<string, unknown> | undefined): string {
|
|
12
|
+
const script = isScript(lang)
|
|
13
|
+
if (!script && !isPython(lang)) return ''
|
|
14
|
+
return Object.entries(params ?? {}).map(([name, value]) => `${script ? 'const ' : ''}${name} = ${literal(value, script)}\n`).join('')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function shownOutput(runs: readonly RunResult[], recorded: string | undefined): { text: string, recorded: boolean } {
|
|
18
|
+
if (recorded !== undefined && !(runs.length > 0 && runs.every(run => run.ok))) return { text: recorded, recorded: true }
|
|
19
|
+
return { text: runs.map(outputText).join(''), recorded: false }
|
|
20
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { RunRequest } from '@lincyaw/kernel'
|
|
2
|
+
import { defineService } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
export interface TraceEvent {
|
|
5
|
+
line: number
|
|
6
|
+
output: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Trace {
|
|
10
|
+
events: TraceEvent[]
|
|
11
|
+
truncated: boolean
|
|
12
|
+
error?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TraceFrame {
|
|
16
|
+
step: number
|
|
17
|
+
line?: number
|
|
18
|
+
output: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function frameAt(trace: Trace, step: number): TraceFrame {
|
|
22
|
+
const at = Math.max(0, Math.min(step, trace.events.length))
|
|
23
|
+
const event = trace.events[at - 1]
|
|
24
|
+
return { step: at, line: event?.line, output: event?.output ?? '' }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Tracer {
|
|
28
|
+
supports(lang: string): boolean
|
|
29
|
+
trace(request: RunRequest, signal?: AbortSignal): Promise<Trace>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const TRACER = defineService<Tracer>('presmium.exec.tracer')
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { componentCode, docBlocks } from '@lincyaw/kernel'
|
|
2
|
+
import type { Check, CodeNode, Finding, IRBlock } from '@lincyaw/kernel'
|
|
3
|
+
import { EXEC_ID, importableInBrowser, isPython, pythonImports } from '../domain/index.ts'
|
|
4
|
+
|
|
5
|
+
function browserPython(block: IRBlock): CodeNode | undefined {
|
|
6
|
+
const { node } = block
|
|
7
|
+
if (node.type === 'code') {
|
|
8
|
+
const run = node.run
|
|
9
|
+
return run && run.when !== 'compile' && !run.executor && isPython(run.lang) ? node : undefined
|
|
10
|
+
}
|
|
11
|
+
if (node.type === 'component' && node.plugin === EXEC_ID && node.name === 'PmStepRun' && isPython(String(node.props.lang ?? 'python'))) return componentCode(node)
|
|
12
|
+
return undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Warns of Python that also runs in the browser and imports modules Pyodide does not have. */
|
|
16
|
+
export const browserImportsCheck: Check = {
|
|
17
|
+
id: 'presmium.exec',
|
|
18
|
+
async run(ctx) {
|
|
19
|
+
const findings: Finding[] = []
|
|
20
|
+
for (const { block } of docBlocks(ctx.ir)) {
|
|
21
|
+
const code = browserPython(block)
|
|
22
|
+
const missing = code ? pythonImports(code.value).filter(name => !importableInBrowser(name)) : []
|
|
23
|
+
if (missing.length === 0) continue
|
|
24
|
+
findings.push({
|
|
25
|
+
checkId: 'presmium.exec',
|
|
26
|
+
severity: 'warning',
|
|
27
|
+
message: `this Python code also runs in the browser, which has the standard library only: ${missing.join(', ')} cannot be imported there`,
|
|
28
|
+
hint: 'run it at compile time only (:run: compile), or write it with the standard library',
|
|
29
|
+
source: { file: block.source.file, line: block.source.line },
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
return findings
|
|
33
|
+
},
|
|
34
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { failedRun } from '@lincyaw/kernel'
|
|
2
|
+
import type { RunContext, RunOutput, RunRequest, RunResult, SandboxResult } from '@lincyaw/kernel'
|
|
3
|
+
import { directSandbox, minimalRunEnv, NODE_RESOURCE_ARGS, RUN_REQUEST_FILE, runResultFile } from '@lincyaw/kernel/node'
|
|
4
|
+
import { DEFAULT_EXEC_CONFIG } from '../domain/index.ts'
|
|
5
|
+
|
|
6
|
+
interface RunnerOutput {
|
|
7
|
+
stdout: string
|
|
8
|
+
stderr: string
|
|
9
|
+
exitCode: number
|
|
10
|
+
outputs?: RunOutput[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const UNSETTLED_AWAIT = 13
|
|
14
|
+
|
|
15
|
+
/** How an executor starts the child process that runs its requests: the command, the paths it reads, whether it keeps sessions. */
|
|
16
|
+
export interface ChildLaunchSpec {
|
|
17
|
+
executorId: string
|
|
18
|
+
argv: string[]
|
|
19
|
+
read(): string[]
|
|
20
|
+
sessions: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Pending {
|
|
24
|
+
request: RunRequest
|
|
25
|
+
signal?: AbortSignal
|
|
26
|
+
ctx?: RunContext
|
|
27
|
+
resolve(result: RunResult): void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface RunSession {
|
|
31
|
+
history: RunRequest[]
|
|
32
|
+
queue: Pending[]
|
|
33
|
+
tail: Promise<void>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Runs requests in a child process, in the process sandbox when one is configured: requests of a session that arrive
|
|
38
|
+
* together go into one run, and a later one first runs the session's earlier requests again.
|
|
39
|
+
*/
|
|
40
|
+
export function createChildProcessRuns(launch: ChildLaunchSpec) {
|
|
41
|
+
const runSessions = new Map<string, RunSession>()
|
|
42
|
+
|
|
43
|
+
async function batch(requests: RunRequest[], skip: number, signal?: AbortSignal, ctx?: RunContext): Promise<RunResult[]> {
|
|
44
|
+
const id = launch.executorId
|
|
45
|
+
const session = launch.sessions ? requests[0]?.session : undefined
|
|
46
|
+
const input = { session, runs: requests.map(({ code, args, stdin, files, lang }) => ({ code, args, stdin, files, lang })) }
|
|
47
|
+
const timeouts = requests.map(r => r.timeoutMs ?? DEFAULT_EXEC_CONFIG.timeoutMs)
|
|
48
|
+
const timeoutMs = timeouts.reduce((sum, t) => sum + t, 0)
|
|
49
|
+
const sandbox = ctx?.sandbox ?? directSandbox
|
|
50
|
+
const started = Date.now()
|
|
51
|
+
let outcome: SandboxResult
|
|
52
|
+
try {
|
|
53
|
+
outcome = await sandbox.run({
|
|
54
|
+
argv: launch.argv[0] === process.execPath ? [process.execPath, ...NODE_RESOURCE_ARGS, ...launch.argv.slice(1)] : launch.argv,
|
|
55
|
+
files: { [RUN_REQUEST_FILE]: JSON.stringify(input) },
|
|
56
|
+
collect: requests.map((_, i) => runResultFile(i)),
|
|
57
|
+
env: ctx?.env ?? minimalRunEnv(),
|
|
58
|
+
read: launch.read(),
|
|
59
|
+
timeoutMs,
|
|
60
|
+
signal,
|
|
61
|
+
})
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return requests.slice(skip).map(() => failedRun(id, `the ${sandbox.kind} sandbox failed: ${error instanceof Error ? error.message : String(error)}`, Date.now() - started))
|
|
64
|
+
}
|
|
65
|
+
const durationMs = Date.now() - started
|
|
66
|
+
let missing = false
|
|
67
|
+
return requests.map((_, i): RunResult => {
|
|
68
|
+
const file = outcome.files[runResultFile(i)]
|
|
69
|
+
let output: RunnerOutput | undefined
|
|
70
|
+
try {
|
|
71
|
+
output = file ? JSON.parse(Buffer.from(file).toString('utf8')) as RunnerOutput : undefined
|
|
72
|
+
} catch {}
|
|
73
|
+
if (output) return { ok: output.exitCode === 0, stdout: output.stdout, stderr: output.stderr, exitCode: output.exitCode, durationMs, timedOut: false, source: 'live', executorId: id, ...output.outputs ? { outputs: output.outputs } : {} }
|
|
74
|
+
const first = !missing
|
|
75
|
+
missing = true
|
|
76
|
+
if (!first) return failedRun(id, 'Not run: an earlier block of the session did not finish', durationMs)
|
|
77
|
+
if (outcome.exitCode === null && !signal?.aborted) return { ...failedRun(id, `Stopped after ${timeouts.slice(0, i + 1).reduce((sum, t) => sum + t, 0)} ms`, durationMs), timedOut: true }
|
|
78
|
+
if (outcome.exitCode === UNSETTLED_AWAIT) return { ...failedRun(id, 'The program ended while a top-level await was still pending', durationMs), exitCode: outcome.exitCode }
|
|
79
|
+
return failedRun(id, outcome.stderr || `the ${id} runner produced no result`, durationMs)
|
|
80
|
+
}).slice(skip)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function flush(state: RunSession): Promise<void> {
|
|
84
|
+
const queue = state.queue
|
|
85
|
+
state.queue = []
|
|
86
|
+
const skip = state.history.length
|
|
87
|
+
const requests = [...state.history, ...queue.map(p => p.request)]
|
|
88
|
+
const results = await batch(requests, skip, queue[0]?.signal, queue[0]?.ctx)
|
|
89
|
+
state.history = requests
|
|
90
|
+
queue.forEach((pending, i) => pending.resolve(results[i]!))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
run(request: RunRequest, signal?: AbortSignal, ctx?: RunContext): Promise<RunResult> {
|
|
95
|
+
const name = launch.sessions ? request.session : undefined
|
|
96
|
+
if (!name) return batch([request], 0, signal, ctx).then(results => results[0]!)
|
|
97
|
+
let state = runSessions.get(name)
|
|
98
|
+
if (!state) runSessions.set(name, state = { history: [], queue: [], tail: Promise.resolve() })
|
|
99
|
+
const runSession = state
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
runSession.queue.push({ request, signal, ctx, resolve })
|
|
102
|
+
if (runSession.queue.length === 1) runSession.tail = runSession.tail.then(() => new Promise(next => setImmediate(next))).then(() => flush(runSession))
|
|
103
|
+
})
|
|
104
|
+
},
|
|
105
|
+
async endSession(session: string): Promise<void> {
|
|
106
|
+
runSessions.delete(session)
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CodeNode, ComponentNode, DirectiveContext, DirectiveInput, IRBlock, RunSpec } from '@lincyaw/kernel'
|
|
2
|
+
import { parse } from 'yaml'
|
|
3
|
+
import { configuredTimeout, EXEC_ID, isPython, isScript, stringArgs } from '../domain/index.ts'
|
|
4
|
+
|
|
5
|
+
type Params = Record<string, unknown>
|
|
6
|
+
|
|
7
|
+
function params(value: unknown, lang: string, ctx: DirectiveContext): Params | undefined {
|
|
8
|
+
if (value === undefined) return undefined
|
|
9
|
+
let data = value
|
|
10
|
+
try {
|
|
11
|
+
if (typeof value === 'string') data = parse(value)
|
|
12
|
+
}
|
|
13
|
+
catch {}
|
|
14
|
+
const variables = isPython(lang) || isScript(lang)
|
|
15
|
+
if (data !== null && typeof data === 'object' && !Array.isArray(data)
|
|
16
|
+
&& (!variables || Object.values(data).every(v => ['string', 'number', 'boolean'].includes(typeof v)))) {
|
|
17
|
+
return data as Params
|
|
18
|
+
}
|
|
19
|
+
ctx.report({ checkId: 'presmium.exec.syntax', severity: 'error', message: variables ? ':params: must be a mapping of names to values, e.g. {n: 5}' : ':params: must be a mapping, e.g. {n: 5}' })
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function positive(value: unknown): number | undefined {
|
|
24
|
+
const n = Number(value)
|
|
25
|
+
return value !== undefined && Number.isFinite(n) && n > 0 ? n : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The component of a directive whose body is code (the run panel, the step run), with the code as its child block. */
|
|
29
|
+
export function codeComponent(name: string, input: DirectiveInput, ctx: DirectiveContext, run?: Omit<RunSpec, 'lang'>): ComponentNode {
|
|
30
|
+
const lang = input.arg?.trim() || 'python'
|
|
31
|
+
const value = input.body.replace(/\n+$/, '')
|
|
32
|
+
const props: Record<string, unknown> = { lang, code: value }
|
|
33
|
+
const values = params(input.options.params, lang, ctx)
|
|
34
|
+
if (values) props.params = values
|
|
35
|
+
const timeout = positive(input.options.timeout) ?? configuredTimeout(ctx.config)
|
|
36
|
+
if (timeout) props.timeout = timeout
|
|
37
|
+
const code: CodeNode = { type: 'code', lang, value }
|
|
38
|
+
if (run) code.run = { lang, ...run, ...values ? { args: stringArgs(values) } : {}, ...timeout ? { timeoutMs: timeout } : {} }
|
|
39
|
+
const source: IRBlock = { id: 'source', role: 'visual', at: 0, source: ctx.source, node: code }
|
|
40
|
+
return {
|
|
41
|
+
type: 'component',
|
|
42
|
+
plugin: EXEC_ID,
|
|
43
|
+
name,
|
|
44
|
+
props,
|
|
45
|
+
children: [source],
|
|
46
|
+
fallback: { type: 'root', children: [{ type: 'code', lang, value }] },
|
|
47
|
+
}
|
|
48
|
+
}
|