@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.
Files changed (52) hide show
  1. package/LICENSE +202 -0
  2. package/LICENSES +696 -0
  3. package/README.md +33 -2
  4. package/components/PmRunPanel.css +167 -0
  5. package/components/PmRunPanel.tsx +389 -0
  6. package/components/PmStepRun.css +61 -0
  7. package/components/PmStepRun.tsx +202 -0
  8. package/package.json +50 -3
  9. package/src/client/RunOutputs.css +21 -0
  10. package/src/client/RunOutputs.tsx +69 -0
  11. package/src/client/StepMark.css +3 -0
  12. package/src/client/StepMark.tsx +12 -0
  13. package/src/client/env.d.ts +13 -0
  14. package/src/client/feedback.ts +69 -0
  15. package/src/client/format.ts +30 -0
  16. package/src/client/index.ts +14 -0
  17. package/src/client/messages.ts +102 -0
  18. package/src/client/outputs.ts +29 -0
  19. package/src/client/plugin.ts +55 -0
  20. package/src/client/pyodide/executor.ts +94 -0
  21. package/src/client/pyodide/presmium_gpu.py +297 -0
  22. package/src/client/pyodide/protocol.ts +27 -0
  23. package/src/client/pyodide/pyodide.worker.ts +68 -0
  24. package/src/client/pyodide/run_await.py +57 -0
  25. package/src/client/runs-in.ts +27 -0
  26. package/src/client/script/executor.ts +62 -0
  27. package/src/client/script/script.worker.ts +9 -0
  28. package/src/client/slide-fit.ts +61 -0
  29. package/src/client/step-marks.ts +7 -0
  30. package/src/client/when-service.ts +14 -0
  31. package/src/domain/browser-python.ts +33 -0
  32. package/src/domain/config.ts +41 -0
  33. package/src/domain/index.ts +11 -0
  34. package/src/domain/outputs.ts +18 -0
  35. package/src/domain/router.ts +50 -0
  36. package/src/domain/runs.ts +72 -0
  37. package/src/domain/static-output.ts +20 -0
  38. package/src/domain/trace.ts +32 -0
  39. package/src/node/browser-imports.ts +34 -0
  40. package/src/node/child-process.ts +109 -0
  41. package/src/node/code-component.ts +48 -0
  42. package/src/node/notebook-cells.ts +46 -0
  43. package/src/node/presmium.ts +27 -0
  44. package/src/node/pyodide.ts +22 -0
  45. package/src/node/python.ts +40 -0
  46. package/src/node/run-panel.ts +71 -0
  47. package/src/node/script-main.mjs +11 -0
  48. package/src/node/script.ts +26 -0
  49. package/src/node/step-run.ts +71 -0
  50. package/src/node/timeouts.ts +15 -0
  51. package/src/shared/script-runner.d.mts +16 -0
  52. 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
+ }