// Run-per-execution pulseq runner: each run boots a fresh numbl/browser // session (no mip, no persistence) with the vendored +mr tree and the user's // script staged as main.m, executes it, then collects every .seq file the // script wrote into the session cwd. Cancel = dispose the worker, which // always works (no cross-origin isolation needed). import { createNumblSession, type NumblSession } from 'numbl/browser' import { mrBootFiles } from './mrFiles' export interface SeqOutputFile { name: string text: string } export interface RunResult { ok: boolean /** True when the run was cancelled (or timed out) rather than finishing. */ aborted?: boolean /** Concatenated console output (also streamed via onOutput). */ output: string /** Formatted MATLAB error message when ok is false. */ error?: string /** .seq files the script wrote, in directory order. */ seqFiles: SeqOutputFile[] elapsedMs: number } export interface RunHooks { /** Streaming console output from the script. */ onOutput?: (text: string) => void } export interface RunHandle { promise: Promise /** Stop the run by terminating the worker. The promise resolves aborted. */ cancel(): void } const RUN_TIMEOUT_MS = 600_000 // Lists the .seq files the script produced, on output lines the runner can // pick out. Runs in the same workspace after main.m, but the session is // disposed right after, so the seqlab_* variables never collide with anything. const COLLECT_SNIPPET = `seqlab_d = dir('*.seq'); for seqlab_i = 1:numel(seqlab_d) fprintf('SEQLAB_FILE:%s\\n', seqlab_d(seqlab_i).name); end ` export function runScript(script: string, hooks: RunHooks = {}): RunHandle { let session: NumblSession | null = null let cancelled = false let timeoutId: ReturnType | undefined const cancel = () => { cancelled = true session?.dispose() } const promise = (async (): Promise => { const t0 = performance.now() let streaming = true const outputs: string[] = [] timeoutId = setTimeout(cancel, RUN_TIMEOUT_MS) try { session = await createNumblSession({ files: [...mrBootFiles(), { path: 'main.m', content: script }], mip: false, persistSystem: false, optimization: '1', onOutput: (text) => { if (!streaming) return outputs.push(text) hooks.onOutput?.(text) }, }) // A cancel during boot has no worker to terminate yet — honor it now. if (cancelled) throw new Error('cancelled') // Semicolon: execute uses REPL display semantics, and a bare `main` // would display a spurious `ans` after the script returns. const result = await session.execute('main;') streaming = false // Collect .seq outputs even after an error — the script may have // written some before failing. const seqFiles: SeqOutputFile[] = [] const collect = await session.execute(COLLECT_SNIPPET) if (collect.ok) { const names = [...collect.output.matchAll(/^SEQLAB_FILE:(.+)$/gm)].map((m) => m[1]) const decoder = new TextDecoder() for (const name of names) { const bytes = await session.readFile(name) seqFiles.push({ name, text: decoder.decode(bytes) }) } } return { ok: result.ok, aborted: result.aborted, output: outputs.join(''), error: result.error, seqFiles, elapsedMs: performance.now() - t0, } } catch (err) { if (cancelled) { return { ok: false, aborted: true, output: outputs.join(''), error: 'Run cancelled', seqFiles: [], elapsedMs: performance.now() - t0, } } throw err } finally { clearTimeout(timeoutId) session?.dispose() session = null } })() return { promise, cancel } }