event-horizon / src /engine /pyrel-worker.js
maxdemarzi's picture
Deploy 4ed0390343adb220188e58f95ab1a8e7e1dd995e (manual: Actions blocked on billing) (part 2)
1b9ed71 verified
Raw
History Blame Contribute Delete
88.9 kB
/* The PyRel front-end, in a Web Worker.
*
* Everything is inside Pyodide now:
*
* Pyodide CPython 3.14 on wasm
* duckdb (wheel) a real DuckDB 1.5.4 built for Pyodide — swan's own, because
* Pyodide's stock package is 1.5.1 and the extension is built
* against 1.5.4
* swan extension loaded by DuckDB itself, found on sys.path
* pyrel_duckdb one Nuitka-compiled wasm32-emscripten module, no source
*
* It is a worker for the ordinary reason: this is a hundred megabytes of wasm
* and tens of seconds of work, and the main thread should not be holding it.
* DuckDB lives inside Pyodide, so there is no JS underneath: no bridge to keep
* synchronous, no shim, and no way for sql() and
* pyrel() to end up looking at different databases. What remains is that this is
* ~100 MB of wasm and multi-second queries, which should not run on the thread
* that paints. The main thread talks to it in messages and never blocks.
*
* Verified as an arrangement by tools/verify_pyrel_wasm.mjs, which runs these
* same pieces under Node against the same wasm binaries. This file is the
* browser wiring of that; it has not itself been run in a browser.
*/
const BASE = new URL('../../web/swan-wasm/', import.meta.url);
let db = null;
let conn = null;
let pyodide = null;
let dbReady = false;
let engineManifest = null;
let pyReady = false;
/* The three artifacts swan publishes for Pyodide, staged next to each other.
* Named here rather than hardcoded at each use so the manifest can carry them
* and a version bump is one place. */
let PY = {
duckdbWheel: 'duckdb-1.5.4-cp314-cp314-pyemscripten_2026_0_wasm32.whl',
module: 'pyrel_duckdb.cpython-314-wasm32-emscripten.so',
extension: 'swan.duckdb_extension.wasm',
};
/* Every page that connects gets a port, and they all stay open.
*
* This is a SharedWorker, so one engine outlives any single page. A reply goes
* back to the port that asked; progress and the rules result go to everyone,
* because a page that attached midway through a load has just as much reason to
* see them as the page that started it. */
const ports = new Set();
const broadcast = (msg) => {
for (const p of ports) {
// A page that navigated away leaves a port that throws rather than one that
// reports itself closed, so drop it here rather than accumulating dead ones.
/* Dropping the port here is safe; dropping its *session* is not. A throw
* from postMessage is not a reliable "this page is gone" signal — a live
* page can miss one — and tearing the session down on that basis deleted
* the namespace of a tab that was mid-run, which then met "no template
* session s1 (have: s2)". Sessions are released on the page's own say-so
* (templateClose) and bounded below, not guessed at from a failed send. */
try { p.postMessage(msg); } catch { ports.delete(p); }
}
};
const progress = (text) => broadcast({ type: 'progress', text });
/* The synchronous bridge Python calls into. Every statement pyrel_duckdb issues
* arrives here.
*
* Parameters go through a prepared statement: `conn.query(sql, params)` silently
* ignores its second argument, which is not a small bug — Swan's compiler is
* invoked as a parameterized query, so unbound parameters disable compilation
* while everything still looks like it is working. */
function execSql(sql) {
if (!pyReady) {
throw new Error('SQL needs the PyRel front-end started — the database lives '
+ 'inside Pyodide now, so call startPyrel() first');
}
pyodide.globals.set('_eh_sql', sql);
return pyodide.runPython(`
_cur = _eh_con().execute(_eh_sql)
_cols = [d[0] for d in (_cur.description or [])]
json.dumps({"columns": _cols, "rows": [list(r) for r in _cur.fetchall()]}, default=str)
`);
}
/* No database on this side.
*
* DuckDB runs inside Pyodide, from a wheel, so this side owns nothing. One
* database means sql() and pyrel() cannot end up looking at different data.
*
* ready() still resolves before Pyodide, because the ~50 MB behind startPyrel()
* should stay opt-in. It just answers from the manifest now instead of from a
* live connection.
*/
async function initDb() {
const manifest = await (await fetch(new URL('manifest.json', BASE))).json();
if (manifest.python && typeof manifest.python === 'object') {
// A staged manifest names the three artifacts; the defaults above are only
// a fallback for a stage written before they were recorded.
PY = { ...PY, ...manifest.python };
}
dbReady = true;
engineManifest = manifest;
return { duckdbLoaded: false, swan: true, manifest };
}
/* ---- the database, cached in IndexedDB ---------------------------------
*
* The engine dies when the last page holding it goes away (see wasm_engine.js
* on why a SharedWorker does not survive navigating to a page with no engine).
* What survives is this: the DuckDB file itself, kept in IndexedDB and written
* back into Pyodide's filesystem before the tables would otherwise be built.
*
* What that saves is the parquet fetch and 24 CREATE TABLEs -- about 8 s of a
* 25 s load, measured. What it does NOT save is the rules, and that is worth
* stating because it is the expensive part and the obvious thing to expect: a
* fresh Python process re-declares every rule whether or not its output tables
* are already there, so restoring a materialised database changes nothing
* (22.0 s warm against 23.1 s cold, measured in CPython). Pyodide's own boot is
* likewise untouched. This is a third off, not a shortcut to instant.
*/
const IDB = { name: 'event-horizon', store: 'duckdb', version: 1 };
const DB_FILE = '/eh/eh.duckdb';
function idb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(IDB.name, IDB.version);
req.onupgradeneeded = () => {
if (!req.result.objectStoreNames.contains(IDB.store)) req.result.createObjectStore(IDB.store);
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function idbGet(db, key) {
return new Promise((resolve, reject) => {
const req = db.transaction(IDB.store, 'readonly').objectStore(IDB.store).get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function idbPut(db, key, value) {
return new Promise((resolve, reject) => {
const tx = db.transaction(IDB.store, 'readwrite');
tx.objectStore(IDB.store).put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/* Everything, not the one key for the dataset in hand.
*
* Reload data means "forget what you cached and read the source again", and the
* store holds nothing but this app's database snapshots -- so clearing it also
* collects the entries left by earlier swan releases and other datasets, which
* are dead weight no other code path ever removes. It also avoids reconstructing
* a key from state that may not be loaded yet. */
function idbClear(db) {
return new Promise((resolve, reject) => {
const tx = db.transaction(IDB.store, 'readwrite');
tx.objectStore(IDB.store).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/* The cache key carries everything a stored file could go stale against: the
* Swan release that wrote it, the DuckDB it was written by, and the dataset
* manifest. A snapshot from a different dataset is not merely unhelpful, it is
* wrong -- the model module would import against someone else's tables -- so
* this errs toward missing rather than toward reuse. */
const snapshotKey = (manifest, dataset) =>
`db:${manifest.swan}:${manifest.duckdb}:${dataset.name || 'dataset'}:${
(dataset.tables || []).map((t) => `${t.table}/${t.rows}`).join(',')}`;
/* What a snapshot records beyond its bytes.
*
* `rules` is the load-bearing field: a database cached before the rules were
* evaluated has the tables but none of the derived columns, and the planned
* fast path (declare the 69 rule declarations, skip the 48 defines -- see
* docs/rules-fast-path.md) would read those columns and answer nothing. So a
* snapshot has to say whether it is complete rather than leaving the reader to
* assume. `v` guards the shape itself: an older entry is bare bytes with no
* fields at all, and must not be read as a record with everything false. */
const SNAPSHOT_V = 1;
/** Write a cached database into place. Null when there was none. */
async function restoreDb(key) {
try {
const rec = await idbGet(await idb(), key);
if (!rec) return null;
if (rec.v !== SNAPSHOT_V || !rec.bytes) {
// A snapshot from an older build of this file. Cheaper to drop than to
// reason about, and it will be rewritten on this load anyway.
progress('ignoring a cached database from an older build');
return null;
}
pyodide.FS.mkdirTree('/eh');
pyodide.FS.writeFile(DB_FILE, new Uint8Array(rec.bytes));
progress('restoring the cached database'
+ `${rec.rules ? ` (${rec.rules.ok}/${rec.rules.total} rules)` : ''}…`);
return rec;
} catch (err) {
/* A cache miss must never be a failure: private browsing, a storage quota,
* or an eviction mid-read all land here, and every one of them is fine --
* the load simply proceeds the slow way. Silence would be wrong too, since
* a cache that never hits looks identical to one that is working. */
progress(`could not read the cached database (${err && err.message || err}) — loading fresh`);
return null;
}
}
/* A content fingerprint for the rules file, so a cached database can be told
* apart from the rules it was computed with.
*
* Not crypto.subtle, which is absent outside a secure context: this is a static
* bundle someone may well serve over plain HTTP on a LAN, and a rules cache
* that silently stopped working only there would be a miserable thing to debug.
* Two FNV-1a accumulators plus the length, which answers the only question ever
* asked of it -- is this the same file the cached values came from? */
const fingerprint = (text) => {
let a = 0x811c9dc5, b = 0x01000193;
for (let i = 0; i < text.length; i += 1) {
const c = text.charCodeAt(i);
a = Math.imul(a ^ c, 0x01000193);
b = Math.imul(b + c, 0x85ebca6b) ^ (b >>> 13);
}
return `${(a >>> 0).toString(16)}-${(b >>> 0).toString(16)}-${text.length}`;
};
/** Store the current database. Best-effort: a failure here costs only speed. */
async function snapshotDb(key, rules = null) {
try {
/* CHECKPOINT first, or the file on disk is missing whatever is still in the
* write-ahead log -- which for a database built entirely in this session is
* potentially all of it. */
execSql('CHECKPOINT');
const bytes = pyodide.FS.readFile(DB_FILE);
await idbPut(await idb(), key, { v: SNAPSHOT_V, rules, bytes });
progress(`cached the database (${(bytes.length / 1e6).toFixed(1)} MB`
+ `${rules ? `, ${rules.ok}/${rules.total} rules` : ''})`);
} catch (err) {
progress(`could not cache the database (${err && err.message || err})`);
}
}
/* Pyodide and pyrel_duckdb, on top of the same connection. Separate because it
* is roughly 100 MB and tens of seconds — a page that paid that on open would
* look broken rather than slow. */
async function initPython() {
progress('booting Pyodide…');
const { loadPyodide } = await import(new URL('pyodide/pyodide.mjs', BASE).href);
pyodide = await loadPyodide({ indexURL: new URL('pyodide/', BASE).href });
progress('loading numpy and pandas…');
await pyodide.loadPackage(['numpy', 'pandas']);
/* DuckDB runs inside Pyodide, from a wheel.
*
* The wheel must be swan's own, not Pyodide's stock `duckdb`: the stock
* package is 1.5.1 and the extension is built against 1.5.4. */
progress('installing duckdb…');
await pyodide.loadPackage(new URL(`python/${PY.duckdbWheel}`, BASE).href);
/* pyrel_duckdb as one Nuitka-compiled wasm32-emscripten module — no Python
* source in the browser at all, which is what rai-swan 0.0.7 made possible
* and, by dropping source from its wheel, also made necessary. */
progress('installing pyrel_duckdb…');
const site = pyodide.runPython('import site; site.getsitepackages()[0]');
for (const name of [PY.module, PY.extension]) {
const bytes = new Uint8Array(
await (await fetch(new URL(`python/${name}`, BASE))).arrayBuffer(),
);
/* Both land in site-packages, and the extension's placement is load-bearing
* rather than tidiness: swan finds it by scanning sys.path entries, because
* Nuitka gives every compiled module a synthetic __file__ (`//pyrel_duckdb/
* …`) with no relation to where the .so actually is, so package-relative
* lookup cannot work. */
pyodide.FS.writeFile(`${site}/${name}`, bytes);
}
progress('starting Swan…');
await pyodide.runPythonAsync(`
import sys, json, contextlib
import duckdb # must precede any Model(): swan promotes duckdb's own
# compiled module to a global symbol so that LOAD swan's
# internal dlopen can resolve against it
from pyrel_duckdb.nlq import execute_pyrel_code, serialize_model_schema
import pyrel_duckdb.nlq.sandbox as _sandbox
# Swan's sandbox caps execution with signal.alarm(), which Emscripten does not
# have -- Pyodide's signal module has no alarm at all, so every call to
# execute_pyrel_code dies with AttributeError before running anything.
#
# Neutralise *only* the timeout, by replacing the context manager rather than
# reimplementing execute_pyrel_code around it. That function does more than
# execute: it validates the AST, builds the restricted namespace, and afterwards
# diffs the model to catch properties the code invented and concepts it renamed
# (the latter being a real exploit -- reassigning Concept.name retargets which
# table a query resolves to). Rewriting it here would risk dropping one of those
# quietly; swapping one contextmanager cannot.
#
# What is genuinely lost is the wall-clock cap. The mitigation is structural:
# this is a Worker, so a runaway query blocks only itself, and the main thread
# can terminate it -- which a signal-based alarm could not have done any better.
@contextlib.contextmanager
def _no_timeout(*_args, **_kwargs):
yield
# Refuse to patch a name that is not there. Plain assignment would create a new
# attribute nothing reads, leave the real _AlarmTimeout in place, and report
# nothing -- the engine would boot clean and every query would then die on
# signal.alarm, which Emscripten does not have. rai-swan is obfuscated from
# 0.0.6, so this name disappearing is a question of when.
if not hasattr(_sandbox, "_AlarmTimeout"):
raise RuntimeError(
"pyrel_duckdb.nlq.sandbox has no _AlarmTimeout. The browser engine "
"replaces it because Pyodide's signal module has no alarm() (swan#152), "
"and it cannot run without doing so. If the name was renamed or "
"obfuscated, find its replacement; if the timeout was removed upstream, "
"delete this patch."
)
_sandbox._AlarmTimeout = _no_timeout
# One database on disk in Pyodide's filesystem, and SWAN_DB_PATH points every
# Model at it. Tables are loaded before the model module is imported (declaring a
# Concept creates its table if absent, so a model built first leaves empty tables
# in its own column order and the data then has nowhere correct to go) -- which
# means the loader and the model must agree on the database. SWAN_DB_PATH is how
# swan 0.0.7 supports that; it is the fix for the gap that previously forced a
# Model.__init__ monkeypatch (GitHub issue #155).
import os
os.makedirs("/eh/data", exist_ok=True)
# Set around the model import, never left standing.
#
# SWAN_DB_PATH is read by *every* Model(), not just this dataset's. Setting it
# here, once, at startup meant a template's Model("diet") -- which its notebook
# writes expecting the ':memory:' default — silently opened /eh/eh.duckdb
# instead. Every template in the browser then shared one database with TPC-DS
# and with each other, which is two bugs wearing one hat:
#
# ConnectionException: Can't open a connection to same database file with a
# different configuration than existing connections
#
# when _eh_con() already held that file on different terms than swan's Model
# opens it with, and
#
# CatalogException: Column with name is_structuring already exists!
#
# when a second template session declared a property the first had already
# added to what was, unknown to either, the same table. Both looked like a
# broken template and neither pointed anywhere near this line.
def _eh_own_db(on):
if on:
os.environ["SWAN_DB_PATH"] = "/eh/eh.duckdb"
else:
os.environ.pop("SWAN_DB_PATH", None)
_eh_own_db(False)
_EH = {"model": None, "con": None}
def _eh_con():
"""The live connection: the model's once it exists, otherwise a transient one
onto the same file. DuckDB will not open a file twice at once, so the loader
closes its own before the model module is imported."""
if _EH["con"] is None:
_EH["con"] = duckdb.connect("/eh/eh.duckdb")
return _EH["con"]
def _eh_release():
if _EH["con"] is not None:
_EH["con"].close()
_EH["con"] = None
`);
pyReady = true;
/* Both versions, because the pairing is the thing that breaks: the extension
* is built against one DuckDB and refuses to load against another, and there
* is a stock Pyodide `duckdb` at a different version that pip would happily
* have resolved instead. Reporting them makes a mismatch visible in the UI
* rather than only as a LOAD failure. */
return {
duckdb: pyodide.runPython('duckdb.__version__'),
swan: pyodide.runPython('import pyrel_duckdb; getattr(pyrel_duckdb, "__version__", "?")'),
};
}
/* Load the staged demo dataset, then the model that describes it.
*
* Order matters and is not a preference: declaring a Concept creates its table
* when the table is not already there, so a model loaded first leaves empty
* tables behind in its own column order and the data then has nowhere correct
* to go. Tables first, and the concepts bind to real ones.
*
* Parquet is fetched in JS and handed to Pyodide's filesystem — see the note
* inside loadDataset on why DuckDB does no HTTP of its own here. */
async function loadDataset() {
const base = new URL('dataset/', BASE);
const manifest = await (await fetch(new URL('manifest.json', base))).json();
/* Fetch each file here and hand DuckDB a buffer, rather than giving it a URL.
*
* `read_parquet('http://…')` needs the httpfs extension, and this database's
* `custom_extension_repository` points at the staged directory that holds only
* Swan — so an implicit INSTALL looks in the wrong place. What it actually did
* was hang: the first table never returned and the load sat there forever
* with no error. Fetching in JS avoids DuckDB doing any HTTP at all. */
/* A cached database stands in for the whole parquet step.
*
* It has to happen before anything opens the file -- restoring underneath a
* live connection would leave DuckDB holding a handle to bytes that are no
* longer what it read. Nothing has called _eh_con() yet at this point, which
* is why this sits at the top rather than beside the loop it replaces. */
const key = snapshotKey(engineManifest || {}, manifest);
const cached = await restoreDb(key);
const restored = !!cached;
pyodide.FS.mkdirTree('/eh/data');
for (const { table, file, rows } of (restored ? [] : manifest.tables)) {
progress(`loading ${table} (${rows.toLocaleString()} rows)…`);
const bytes = new Uint8Array(await (await fetch(new URL(file, base))).arrayBuffer());
/* Into Pyodide's filesystem, because that is where DuckDB is. The file
* simply exists and read_parquet opens it like any other path. */
pyodide.FS.writeFile(`/eh/data/${file}`, bytes);
// The statement is built here and handed over as a value. Composing it
// inside runPython means an f-string nested in a JS template literal, three
// levels of quoting for no gain.
execSql(`CREATE OR REPLACE TABLE "${table}" AS `
+ `SELECT * FROM read_parquet('/eh/data/${file}')`);
pyodide.FS.unlink(`/eh/data/${file}`); // the rows are in the table now
}
/* The model may be several modules that import each other — TPC-DS is five,
* with model.py importing the sales-channel modules at the bottom and each of
* those doing `from model import …` back. They resolve only when all of them
* sit in one directory on sys.path, so write the files and import the entry
* rather than concatenating anything. */
/* Hand the database over. DuckDB will not open one file from two
* connections at once, and the model module is about to open it via
* SWAN_DB_PATH, so the loader's connection has to go first. */
pyodide.runPython('_eh_release()');
progress('building the model…');
const spec = manifest.model;
const files = {};
for (const name of spec.files) {
files[name] = await (await fetch(new URL(name, base))).text();
}
const info = JSON.parse(await loadModel({ entry: spec.entry, files }));
await loadPromptModule(manifest, base);
/* Rules are not awaited. Evaluating the 117 statements costs about 40s here,
* and measurement says none of it is recoverable: exec'ing them is 19.3s of a
* 19.9s total in CPython, so the expense is Swan's own define() work rather
* than the flush, and restoring an already-materialised database changes
* nothing because a fresh process re-declares everything anyway (22.0s warm
* against 23.1s cold).
*
* So the fix is not to make it faster but to stop charging the whole of it
* before the first question. The concept tier is complete the moment the model
* is imported; the rules tier arrives about 35 seconds later, and a `rules`
* message says when. */
if (manifest.rules) {
fetch(new URL(manifest.rules, base))
.then((res) => res.text())
.then(async (source) => {
/* Take the fast path only if the cached values were computed from *this*
* rules file. The cache key covers the swan release, the DuckDB version
* and the dataset's tables — none of which move when someone edits a
* rule, so without this an edited rules.py would be declared over stale
* values and answer confidently wrong numbers. Hashing the source turns
* that into a recompute.
*
* A snapshot written before this check existed carries no fingerprint,
* so it mismatches and earns one full run before going fast again —
* which is the right direction to fail in, and cheaper than throwing
* away 21 MB that is still perfectly good. */
const digest = fingerprint(source);
const usable = restored && cached.rules && cached.rules.ok > 0;
const fresh = usable && cached.rules.source === digest;
if (usable && !fresh) progress('the rules changed since this database was cached — recomputing…');
const rules = await loadRules(source, fresh ? cached.rules : null);
progress(rules.fast
? `rules: ${rules.ok} of ${rules.total} in force `
+ `(${rules.fast.declared} declared, ${rules.fast.skipped} reused from cache)`
: `rules: ${rules.ok} of ${rules.total} evaluated`);
rulesInfo = rules;
broadcast({ type: 'rules', rules });
/* Snapshot here rather than beside the parquet load, because only now is
* there anything worth caching for the fast path: the derived columns
* exist. Cached before this point, the database has the tables and none
* of the rule outputs, and declaring rules over it would read empty
* columns and answer nothing -- silently, which is the worst version.
*
* After the broadcast, deliberately. Nothing waits on the cache, so a
* page should hear that its rules are ready before this spends a second
* writing tens of megabytes to IndexedDB.
*
* The tally goes in the record rather than being required to be
* perfect. 116 of 117 is TPC-DS's *normal* state -- one statement is
* non-stratifiable and loadRules rolls it back every time -- so
* demanding ok === total would have cached nothing, forever, silently.
* What matters is not that every rule evaluated but that a later load
* can tell which ones did, so store the count and let the reader
* decide. The failure records ride along too, so a page on the fast path
* can still name the rule that did not evaluate rather than quietly
* presenting 116 rules as if they were 117.
*
* Written on a cold load, and again when a restored database had to
* recompute because the rules changed -- otherwise the next load would
* pay for the same edit a second time. The wart in re-snapshotting: a
* table left behind by a rule that was *deleted* is carried forward too,
* since this stores the database as it stands. Reload data is the way
* out of that, and it is the reason that button exists. */
if (!restored || !fresh) {
await snapshotDb(key, {
ok: rules.ok, total: rules.total, failures: rules.failures, source: digest,
});
}
})
.catch((err) => {
rulesInfo = { error: String(err && err.message || err) };
broadcast({ type: 'rules', error: rulesInfo.error });
});
}
/* Report the cache outcome rather than leaving it to be inferred.
*
* A restore that silently did not happen looks exactly like one that did --
* the tables get built from parquet instead and every query answers the same
* numbers. Scraping the progress line cannot tell them apart either, since
* `restoring the cached database…` is overwritten within milliseconds by
* `building the model…`. So the fact has to travel in the reply, where a test
* can assert it. */
return {
...manifest,
model: info,
rules: null,
rulesPending: !!manifest.rules,
cache: cached ? { restored: true, rules: cached.rules || null } : { restored: false },
};
}
/* Re-read the model and recompute every rule against the data as it stands.
*
* The escape hatch for the fast path. A warm load declares the rules and reuses
* values a previous session computed, guarded by a fingerprint of rules.py --
* which catches an edited rules file but cannot catch a define whose *inputs*
* were changed by something outside the cache key. This is what to press when
* the answers are not trusted, and it is deliberately a button rather than a
* heuristic: the alternative is guessing, and a wrong guess here serves stale
* numbers with no signal at all.
*
* The model module is re-imported rather than reused. Re-declaring a property
* onto a model that already has it is not obviously safe, a fresh import is
* simpler to reason about, and it is what the button says -- the model source is
* read again too, not only the rules. */
async function reloadModel() {
if (!datasetInfo) throw new Error('there is no model loaded to reload');
const base = new URL('dataset/', BASE);
const manifest = await (await fetch(new URL('manifest.json', base))).json();
/* Close the old connection before the new import opens its own. DuckDB shares
* one instance per file within a process, so the two would most likely
* coexist -- but only while their configuration matches, and "most likely" is
* a poor foundation for the button someone reaches for when they already
* suspect something is wrong. */
pyodide.runPython(`
_m = _EH.get("model")
if _m is not None:
try:
_m.con.close()
except Exception:
pass
_EH["model"] = None
# loadModel adopted this connection from the model, so it is the one just closed.
# Clearing it matters if the re-import below fails: _eh_con() then opens a fresh
# one rather than handing every later query a dead handle.
_EH["con"] = None
`);
progress('rebuilding the model…');
const spec = manifest.model;
const files = {};
for (const name of spec.files) {
files[name] = await (await fetch(new URL(name, base))).text();
}
const info = JSON.parse(await loadModel({ entry: spec.entry, files }));
await loadPromptModule(manifest, base);
/* `cache` is left as it was on purpose: it records how the *data* got here,
* which a model rebuild does not change. */
datasetInfo = { ...datasetInfo, model: info };
rulesInfo = null;
if (!manifest.rules) return { model: info, rules: null };
const source = await (await fetch(new URL(manifest.rules, base))).text();
const rules = await loadRules(source, null); // full: defines and all
progress(`rules: ${rules.ok} of ${rules.total} evaluated`);
rulesInfo = rules;
broadcast({ type: 'rules', rules });
/* Re-cache, so the next visit starts from the recomputed values rather than
* repeating this. The fingerprint goes in with them: whatever prompted the
* rebuild, what is stored now was computed from the rules file as it is. */
await snapshotDb(snapshotKey(engineManifest || {}, manifest), {
ok: rules.ok, total: rules.total, failures: rules.failures, source: fingerprint(source),
});
return { model: info, rules };
}
/* Evaluate a rules file one top-level statement at a time, rolling back what
* fails.
*
* A plain import is all-or-nothing, and rule evaluation is deferred: every later
* execute() calls flush_and_evaluate(), so one rule that generates SQL DuckDB
* rejects re-raises on every subsequent query and takes the engine down with it.
* Dark Matter's TPC-DS rules have five such statements out of 117 — without
* this, loading them costs you the other 112 and the browser engine with them.
*
* The same approach as tools/check_rules.py, in Python here because the rules
* are Python and the model they attach to lives in Pyodide. */
async function loadRules(source, cachedTally = null) {
/* Passing a tally puts this on the fast path: declare the rules, do not
* recompute them.
*
* rules.py is two kinds of statement. `Customer.store_spend = m.Property(…)`
* declares a name — it is what makes the property resolve on the model object,
* and skipping it fails every query touching it with "referenced property not
* declared in the schema". `m.define(Customer.store_spend(…))` computes the
* values, which is where all the time goes. Against a database where a
* previous session already materialised those values, running only the
* declarations answers every rule kind correctly — derivations, aggregates and
* classifications alike. Measured in CPython: 20.7 s to 0.6 s, with all five
* expectations from verify_tpcds_browser unchanged. docs/rules-fast-path.md
* has the table.
*
* The filter is structural rather than textual. Declarations are assignments;
* defines are bare expressions wrapping a define() call. Matching the node
* shape means a define inside a string, a comment or a name like `redefine`
* cannot be mistaken for one, which a regex over source lines would do.
*
* Note what this does NOT change: the stepper below, its rollback, its
* failure records. The fast path is a shorter list of nodes handed to exactly
* the same loop, which is deliberate -- a second loading path that skipped the
* rollback would trade 20 s for the entire engine, since one non-stratifiable
* statement poisons every subsequent query, including plain column reads. */
const declareOnly = !!cachedTally;
pyodide.globals.set('_eh_rules_src', source);
pyodide.globals.set('_eh_declare_only', declareOnly);
/* The loop is driven from JS, one statement per call, so it can yield.
*
* As a single runPythonAsync it was 40 seconds of uninterrupted synchronous
* Python: this worker has one thread, so nothing else ran — not a query, not
* even the delivery of the message asking for one. Evaluating in the
* background is only meaningful if the background actually gives the thread
* back, which is what the await below does. */
await pyodide.runPythonAsync(`
import ast as _ast
_model = _EH["model"]
_ns = _EH["ns"]
_tree = _ast.parse(_eh_rules_src, filename="rules.py")
_lines = _eh_rules_src.splitlines()
def _eh_is_define(_n):
# A statement that computes values, rather than one that declares a name.
if not isinstance(_n, _ast.Expr):
return False
for _sub in _ast.walk(_n):
if isinstance(_sub, _ast.Call):
_f = _sub.func
if getattr(_f, "attr", None) == "define" or getattr(_f, "id", None) == "define":
return True
return False
_EH["rule_statements"] = len(_tree.body)
_EH["rule_nodes"] = ([_n for _n in _tree.body if not _eh_is_define(_n)]
if _eh_declare_only else _tree.body)
_EH["rule_failures"] = []
def _marks():
return (len(_model.datalog_rules),
len(getattr(_model, "_pending_rules", []) or []),
getattr(_model, "datalog_dirty", False))
def _rollback(m):
n_datalog, n_pending, dirty = m
del _model.datalog_rules[n_datalog:]
if getattr(_model, "_pending_rules", None) is not None:
del _model._pending_rules[n_pending:]
_model.datalog_dirty = dirty
def _eh_has_bodyless_new(_tree):
"""Any statement creating entities with .new() and no .where() body?
Such a rule is not a Datalog rule: it sits in model._pending_rules and is
consumed by the first flush_pending_rules() that sees it, running exactly
once and never again. With evaluation suppressed that flush would consume it
against still-empty data -- permanently. It is the one shape the fast path
cannot defer, so a file containing one is loaded the slow way.
Conservative on purpose: it asks whether the statement mentions .where at
all, not whether that .where binds to this .new(). Anything it cannot prove
safe is merely slower.
"""
for _n in _tree.body:
_attrs = [_a for _a in _ast.walk(_n) if isinstance(_a, _ast.Attribute)]
if any(_a.attr == "new" for _a in _attrs) and not any(_a.attr == "where" for _a in _attrs):
return True
return False
_EH["rules_bodyless"] = _eh_has_bodyless_new(_tree)
def _eh_rules_fast():
"""Declare the whole file with evaluation suppressed, then evaluate once.
This is tools/swan_sidecar.py's load_rules_file, which measured 26.3s to
1.3s on this same file. The saving is not in skipping our own
flush_and_evaluate -- dropping that does nothing, because
ConnectionProxy.execute() calls it on every statement it runs, including
the DDL each new m.Property() issues, so the flush just moves onto the next
property. auto_evaluate_rules is the flag evaluate_datalog_rules checks
first and returns on, so it is the one that actually defers the work.
Returns "" on success, or the error that made it give up.
"""
_m = _marks()
_prior = getattr(_model, "auto_evaluate_rules", True)
try:
_model.auto_evaluate_rules = False
try:
for _node in _EH["rule_nodes"]:
exec(compile(_ast.Module(body=[_node], type_ignores=[]), "rules.py", "exec"), _ns)
finally:
_model.auto_evaluate_rules = _prior
# define() sets this itself; set it again because the fast path's whole
# premise is that no evaluation has happened yet, and this is the flag
# that says one is owed.
_model.datalog_dirty = True
_model.flush_and_evaluate()
return ""
except Exception as _e:
_model.auto_evaluate_rules = _prior
_rollback(_m)
return str(_e).strip().splitlines()[0][:200] or "evaluation failed"
def _eh_rule_step(i):
_node = _EH["rule_nodes"][i]
_m = _marks()
try:
exec(compile(_ast.Module(body=[_node], type_ignores=[]), "rules.py", "exec"), _ns)
_model.flush_and_evaluate()
except Exception as _e:
_rollback(_m)
# Same record the sidecar reports, so one banner renders either engine.
_EH["rule_failures"].append({"line": _node.lineno,
"statement": _lines[_node.lineno - 1].strip()[:120],
"error": str(_e).strip().splitlines()[0][:200]})
`);
const total = pyodide.runPython('len(_EH["rule_nodes"])');
const bodyless = pyodide.runPython('bool(_EH["rules_bodyless"])');
/* Declare the whole file with evaluation suppressed, then evaluate once.
*
* Every evaluation pass recomputes *every* registered rule from scratch, so
* statement N re-derives the N-1 before it. Measured in this browser on
* TPC-DS the curve is unmistakable — the first rules landed in ~0.4s each and
* the last in ~4s, the file taking over 200s. The sidecar hit the same wall
* and solved it (tools/swan_sidecar.py:load_rules_file, 26.3s → 1.3s); this
* is that fix on the browser side, so the two engines load rules the same way
* as well as answering the same.
*
* Note what does NOT work, because it is the obvious thing to try and I tried
* it: dropping our own flush_and_evaluate() from the loop changes nothing.
* ConnectionProxy.execute() calls it on every statement it runs, including
* the DDL each new m.Property() issues, and rules.py alternates declaration
* with definition — so the flush does not go away, it moves onto the next
* property. `auto_evaluate_rules` is the flag evaluate_datalog_rules checks
* first, which is why it is the one that defers anything.
*
* The per-statement stepper stays, for the two cases that need it. */
let usedFastPath = false;
if (!bodyless) {
progress(`${declareOnly ? 'declaring' : 'evaluating'} ${total} rules…`);
// Yield first: the fast path is one synchronous span, and this worker has a
// single thread. Better to hand it back once here than to look hung.
await new Promise((resolve) => setTimeout(resolve, 0));
const fastError = pyodide.runPython('_eh_rules_fast()');
usedFastPath = !fastError;
if (fastError) progress('a rule did not evaluate — finding which…');
}
/* Statement by statement: either because the file has a bodyless .new() rule
* the fast path cannot defer, or because the single evaluation raised and the
* only way to name the culprit is to reintroduce them one at a time. Rolling
* back a failure matters more than the speed — one non-stratifiable statement
* left in the model poisons every later query, including plain column reads
* that touch no rule. The quadratic cost is now paid only by a file that is
* already failing, or one shaped so it cannot be avoided. */
if (!usedFastPath) {
for (let i = 0; i < total; i += 1) {
pyodide.runPython(`_eh_rule_step(${i})`);
await new Promise((resolve) => setTimeout(resolve, 0));
if (i % 4 === 3 || i === total - 1) {
progress(`${declareOnly ? 'declaring' : 'evaluating'} rules… ${i + 1} of ${total}`);
}
}
}
const ran = JSON.parse(pyodide.runPython(`json.dumps({
"statements": _EH["rule_statements"],
"ran": len(_EH["rule_nodes"]),
"failures": _EH["rule_failures"],
"concepts": len(_EH["model"]._concepts),
})`));
if (!declareOnly) {
return {
total: ran.statements,
ok: ran.ran - ran.failures.length,
failures: ran.failures,
concepts: ran.concepts,
};
}
/* On the fast path `ok` and `total` describe the rules in force, not the
* statements this call ran -- otherwise a page that reused cached values would
* report "69 of 69 rules" and read as though the file had shrunk.
*
* What is in force is what the caching load evaluated, less anything that
* failed to declare now. A failed declaration is not cosmetic: the property
* stops resolving, so its cached values become unreachable and it is correctly
* no longer counted. Its record joins the caching load's own failures, which
* ride along in the snapshot so a warm page can still say which rule did not
* evaluate rather than quietly presenting 116 rules as 117. */
return {
total: cachedTally.total,
ok: Math.max(0, (cachedTally.ok || 0) - ran.failures.length),
failures: [...(cachedTally.failures || []), ...ran.failures],
concepts: ran.concepts,
fast: { declared: ran.ran, skipped: ran.statements - ran.ran },
};
}
/* Stage the fine-tune's prompt shape, when the dataset carries one.
*
* `render_schema` renders a live Swan model in the nested foreign-key form the
* adapter was trained on, which is not what pyrel_duckdb.nlq emits. That is not
* a cosmetic difference: black_swan's own measurements name the flat form as the
* largest single failure class, 11 of 52 evaluation failures naming a property
* that belongs to the parent as if it were local. So the browser renders the
* shape the weights expect.
*
* It runs the exported copy of tools/local_generator.py rather than a JS
* translation of it, because the model is right here in Pyodide and a second
* implementation would drift from the training data with nothing to catch it —
* tools/verify_local_generator.py asserts that file against the training split,
* and a reimplementation would be outside what it checks.
*
* Absent is fine and quiet-ish: a dataset staged before this existed has no
* promptModule, and nlq falls back to its own schema renderer, which is right
* for a general model and merely off-distribution for the fine-tune. */
async function loadPromptModule(manifest, base) {
if (!manifest.promptModule) return;
try {
const source = await (await fetch(new URL(manifest.promptModule, base))).text();
pyodide.FS.writeFile('/eh/model/nlq_prompt.py', source);
pyodide.runPython(`
import importlib, sys
sys.modules.pop("nlq_prompt", None)
_EH["prompt_mod"] = importlib.import_module("nlq_prompt")
`);
} catch (err) {
_EHpromptFailed = String(err && err.message || err);
progress(`the fine-tune prompt shape did not load (${_EHpromptFailed}) — falling back to swan's schema`);
}
}
let _EHpromptFailed = null;
/* Build the model inside Pyodide from source the caller supplies. The model is
* Python, so it is defined the same way it is everywhere else — by running the
* module — rather than by inventing a serialization format for it. */
async function loadModel(spec) {
/* Accepts a bare source string, or {entry, files} for a model split across
* modules. The multi-file form writes them to a directory and imports the
* entry the ordinary way, because these modules import *each other* — TPC-DS's
* model.py imports its four sales-channel modules at the bottom and each of
* those does `from model import …` back. exec'ing a concatenation cannot
* satisfy that; a directory on sys.path can. */
const files = typeof spec === 'string'
? { 'model.py': spec }
: spec.files;
const entry = typeof spec === 'string' ? 'model' : spec.entry;
pyodide.FS.mkdirTree('/eh/model');
for (const [name, text] of Object.entries(files)) {
pyodide.FS.writeFile(`/eh/model/${name}`, text);
}
pyodide.globals.set('_eh_entry', entry);
await pyodide.runPythonAsync(`
import importlib, sys
if "/eh/model" not in sys.path:
sys.path.insert(0, "/eh/model")
# Drop any previous load, so a second call re-imports rather than returning the
# module object from the first one and silently ignoring new source.
for _name in [n for n in sys.modules if n == _eh_entry or n.startswith("model")]:
sys.modules.pop(_name, None)
# Only this import gets to claim /eh/eh.duckdb — see _eh_own_db. The loader and
# the model must agree on one database (swan issue #155); nothing else should.
_eh_own_db(True)
try:
_module = importlib.import_module(_eh_entry)
finally:
_eh_own_db(False)
_EH["model"] = next(
(v for v in vars(_module).values()
if hasattr(v, "_concepts") and hasattr(v, "con")), None
)
if _EH["model"] is None:
raise RuntimeError("no pyrel_duckdb.Model instance found in " + _eh_entry)
# Kept for the rules loader: rules.py expects the model module's own namespace
# (m, Customer, Integer, …) in scope, exactly as importing it normally would.
_EH["ns"] = vars(_module)
# Adopt the model's own connection: from here it is the one holding the file, so
# sql() and pyrel() cannot drift onto different databases.
_EH["con"] = _EH["model"].con
`);
return pyodide.runPython(`json.dumps({
"name": getattr(_EH["model"], "name", "model"),
"concepts": len(_EH["model"]._concepts),
})`);
}
/* Natural language, with generation handed out to whatever the page points at.
*
* Everything except producing text already runs here: `pyrel_duckdb.nlq` is in
* the compiled module, and the worker has been importing it all along for
* execute_pyrel_code and serialize_model_schema. `answer_question` takes the
* model, the question and a `generate_fn` — so the only thing missing from a
* browser-only natural-language path was a way to turn a prompt into a string.
*
* That makes the backend pluggable rather than chosen: a self-hosted fine-tune,
* an OpenAI-compatible server, eventually a model running in this same browser.
* None of them change anything above this function.
*
* XMLHttpRequest rather than fetch, and synchronously, because answer_question
* is synchronous Python: it calls generate_fn(prompt), inspects what comes back,
* and re-prompts around its own sandbox. There is no point in the call stack
* where a promise could be awaited. Sync XHR is barred on the main thread and
* allowed in a worker, which is one more thing this worker is for. The cost is
* that the thread blocks for the length of a generation — the same bargain rule
* evaluation made before it learned to yield, and an explicit question is a
* reasonable place to pay it.
*/
/* Gradio's REST API, for a Space that cannot expose an OpenAI-compatible route.
*
* ZeroGPU -- the free GPU tier -- only works with the Gradio SDK, and the
* platform already runs a server on the Space's ports, so mounting FastAPI
* beside it fights for 7860/7861 and its GPU supervisor never finds a
* @spaces.GPU function because it is watching its own app. A Gradio endpoint is
* what such a Space can offer, so this speaks it. What it buys is f16 instead of
* q4, which is worth about 3 questions in 22 on a wide schema.
*
* Two steps rather than one: POST returns an event id, and a second GET drains
* the result as server-sent events. Both are synchronous XHRs for the same
* reason the OpenAI path is -- answer_question is synchronous Python and there
* is nowhere to await a promise. The SSE body arrives whole because the stream
* closes when the run completes.
*/
/** Host of a URL, for saying which generator answered without printing a path. */
function hostOf(url) {
try {
return new URL(url).host;
} catch {
return String(url);
}
}
/* Gradio's REST API, for a Space that cannot expose an OpenAI-compatible route.
*
* ZeroGPU -- the free GPU tier -- only works with the Gradio SDK, and the
* platform already runs a server on the Space's ports, so mounting FastAPI
* beside it fights for 7860/7861 and its GPU supervisor never finds a
* @spaces.GPU function because it is watching its own app.
*
* Two steps rather than one: POST returns an event id, and a second GET drains
* the result as server-sent events. Both are synchronous XHRs for the same
* reason the OpenAI path is -- answer_question is synchronous Python and there
* is nowhere to await a promise. The SSE body arrives whole because the stream
* closes when the run completes.
*/
function callGradio(endpoint, _model, messagesJson) {
const xhr = new XMLHttpRequest();
xhr.open('POST', endpoint, false);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify({ data: [messagesJson] }));
if (xhr.status < 200 || xhr.status >= 300) {
throw new Error(`answered ${xhr.status}: ${String(xhr.responseText).slice(0, 160)}`);
}
const started = JSON.parse(xhr.responseText);
const id = started && (started.event_id || started.eventId);
if (!id) throw new Error(`no event id: ${JSON.stringify(started).slice(0, 120)}`);
const poll = new XMLHttpRequest();
poll.open('GET', `${endpoint.replace(/\/$/, '')}/${id}`, false);
poll.send();
if (poll.status < 200 || poll.status >= 300) throw new Error(`stream answered ${poll.status}`);
/* The LAST data frame, not the first: the stream carries progress events
* before the result, and an earlier one is a queue position rather than a
* program. A frame of `null` is how this endpoint reports a refused GPU
* allocation -- no message, no traceback -- so it has to count as a failure
* rather than as an empty answer, or the fallback never fires. */
let out = null;
for (const line of String(poll.responseText).split('\n')) {
if (!line.startsWith('data: ')) continue;
try {
const parsed = JSON.parse(line.slice(6));
if (Array.isArray(parsed) && parsed.length && typeof parsed[0] === 'string') out = parsed[0];
} catch { /* keepalives and non-JSON frames */ }
}
if (out === null) {
throw new Error(`no result: ${String(poll.responseText).slice(0, 120).replace(/\n/g, ' ')}`);
}
return out;
}
/* The OpenAI-compatible chat shape, which vLLM, llama.cpp's server, Ollama and
* TGI all speak. `model` is passed through untouched: hosts serving a single
* fine-tune usually ignore it, and ones serving several need it. */
function callOpenAI(endpoint, model, messagesJson) {
const xhr = new XMLHttpRequest();
xhr.open('POST', endpoint, false);
xhr.setRequestHeader('Content-Type', 'application/json');
const body = {
messages: JSON.parse(messagesJson),
// Deterministic. A schema-to-code task has a right answer, and sampling
// makes a wrong one irreproducible.
temperature: 0,
stream: false,
};
if (model) body.model = model;
xhr.send(JSON.stringify(body));
if (xhr.status < 200 || xhr.status >= 300) {
throw new Error(`answered ${xhr.status}: ${String(xhr.responseText).slice(0, 160)}`);
}
let data;
try { data = JSON.parse(xhr.responseText); } catch {
throw new Error(`did not answer JSON: ${String(xhr.responseText).slice(0, 120)}`);
}
const text = data?.choices?.[0]?.message?.content
?? data?.choices?.[0]?.text
?? data?.content?.[0]?.text // Anthropic messages
?? data?.message?.content // Ollama /api/chat
?? data?.response; // Ollama /api/generate
if (typeof text !== 'string') {
throw new Error(`no text in the reply: ${JSON.stringify(data).slice(0, 120)}`);
}
return text;
}
/* Try each generator in turn, and remember which one answered.
*
* The default chain is a token-holding proxy onto a GPU Space, followed by a CPU
* one. The GPU is ~15x faster and runs the weights unquantized, which is worth
* about three questions in twenty-two on a wide schema -- but its daily quota is
* finite and shared by everyone reading the page, and when it runs out the
* allocation is refused in ~0.4s with no status code, no message and no
* traceback. Falling through to the slower host turns that from an outage into a
* slow answer, which is why the chain outlives the problem that created it.
*
* The refusal being *fast* is what makes the chain cheap: a doomed first hop
* costs less than half a second before the real one starts.
*
* Chosen by URL shape rather than a flag, so a chain can mix protocols: the
* GPU Space speaks Gradio because ZeroGPU forces it, the CPU one speaks OpenAI.
*/
function generateVia(endpoints, model) {
const chain = (Array.isArray(endpoints) ? endpoints : [endpoints]).filter(Boolean);
const state = { host: null, fellBack: false, tried: [] };
const generate = (messagesJson) => {
for (let i = 0; i < chain.length; i += 1) {
const url = chain[i];
const call = /\/gradio_api\/call\//.test(url) ? callGradio : callOpenAI;
try {
const out = call(url, model, messagesJson);
state.host = hostOf(url);
state.fellBack = i > 0;
return out;
} catch (err) {
const why = String(err && err.message || err);
state.tried.push(`${hostOf(url)}: ${why}`);
if (i === chain.length - 1) {
throw new Error(`no generator answered — ${state.tried.join('; ')}`);
}
progress(`${hostOf(url)} did not answer, trying ${hostOf(chain[i + 1])}…`);
}
}
throw new Error('no generation endpoint configured');
};
generate.state = state;
return generate;
}
async function runAsk({ question, history, endpoint, model, prune = true }) {
if (!endpoint) throw new Error('no generation endpoint — load the page with ?nlq=<url>');
if (!pyodide.runPython('_EH["model"] is not None')) {
throw new Error('no model loaded — load the dataset first');
}
const bridge = generateVia(endpoint, model);
pyodide.globals.set('_eh_generate_js', bridge);
pyodide.globals.set('_eh_question', question);
pyodide.globals.set('_eh_prune', prune !== false);
pyodide.globals.set('_eh_history', history && history.length ? JSON.stringify(history) : null);
const out = await pyodide.runPythonAsync(`
from pyrel_duckdb.nlq import NLQGenerationError, answer_question
_kw = {}
_pm = _EH.get("prompt_mod")
_eh_state = {"code": "", "escalated": False, "pruned": None, "full": None}
if _eh_history:
_kw["history"] = json.loads(_eh_history)
if _pm is not None:
# Rebuild the adapter's own turn, rather than passing swan's prompt through.
#
# The shape has two halves and only one of them was right before: the system
# prompt was the trained one, but the user turn was still swan's wrapper with
# our schema inside it. LocalGenerator ignores swan's prompt for exactly this
# reason -- it reads only the *error* back out of a retry prompt and builds
# the Schema/Question turn itself. This is the same thing on the JS side of
# the bridge, calling that same build_user_turn and extract_code, so there is
# one definition of the shape rather than a copy to drift from.
#
# Pruning, and the full schema behind it: the first attempt sees only the
# concepts the question appears to need, and a retry escalates to everything.
# TPC-DS renders to 8,203 tokens whole against the 317-640 the adapter was
# trained on, and pruning brings that to ~1,850.
_eh_state["full"] = _pm.render_schema(_EH["model"])
try:
_eh_state["pruned"] = (_pm.render_schema(_EH["model"], _eh_question)
if _eh_prune else _eh_state["full"])
except TypeError:
# The staged module is a *copy* of tools/local_generator.py taken at
# export time, so it can be older than this worker -- a dataset staged
# before pruning existed has a render_schema that takes only the model.
# Falling back to the whole schema costs speed; raising would cost the
# feature, and re-running the export is not something a page can do.
_eh_state["pruned"] = _eh_state["full"]
_eh_sys = getattr(_pm, "SYSTEM", None)
def _eh_generate(_swan_prompt):
_schema = _eh_state["pruned"]
_retry = None
_marker = "# That attempt failed"
if _marker in _swan_prompt and _eh_state["code"]:
_err = _swan_prompt.split(_marker, 1)[1].strip()
_err = _err.split("Write a corrected version", 1)[0].strip()[:800]
_retry = {"code": _eh_state["code"], "error": _err}
_schema = _eh_state["full"]
_eh_state["escalated"] = True
_msgs = []
if _eh_sys:
_msgs.append({"role": "system", "content": _eh_sys})
_msgs.append({"role": "user",
"content": _pm.build_user_turn(_schema, _eh_question)})
if _retry:
# Same minimal correction turn LocalGenerator sends: the failed
# program, the error, and the instruction. The adapter never saw a
# repair turn in training, so anything more elaborate is invention.
_msgs.append({"role": "assistant", "content": _retry["code"]})
_msgs.append({"role": "user", "content":
"That program failed: " + _retry["error"]
+ "\\nReply with only the corrected PyRel program."})
_code = _pm.extract_code(_eh_generate_js(json.dumps(_msgs)))
_eh_state["code"] = _code
return _code
else:
# No staged prompt module: swan's own prompt, unmodified. Right for a general
# model, merely off-distribution for the fine-tune.
def _eh_generate(_swan_prompt):
return _eh_generate_js(json.dumps([{"role": "user", "content": _swan_prompt}]))
try:
_r = answer_question(_EH["model"], _eh_question, _eh_generate, **_kw)
except NLQGenerationError as _e:
# The pipeline has already retried and translated each failure. Surface the
# last attempt rather than "generation failed", so the page can show what was
# tried and why it did not run.
raise RuntimeError(json.dumps({
"error": str(_e),
"code": getattr(_e, "last_code", None),
"detail": getattr(_e, "last_error", None),
}))
json.dumps({
"question": _r.question,
"code": _r.code,
"attempts": _r.attempts,
"rows": _r.result if isinstance(_r.result, list) else str(_r.result),
"examples": [_ex.get("question", "") for _ex in (_r.examples_used or [])],
# Reported rather than inferred: an answer that needed the whole schema is a
# question the pruner got wrong, and that is worth being able to count.
"schema": {
"pruned": bool(_eh_prune and _pm is not None),
"escalated": _eh_state["escalated"],
"chars": len(_eh_state["pruned"] or "") if _eh_state["pruned"] else None,
"fullChars": len(_eh_state["full"] or "") if _eh_state["full"] else None,
},
}, default=str)
`);
const answer = JSON.parse(out);
/* Which generator actually answered, and whether it was the first choice.
* The chain exists because the fast host refuses anonymous callers, so "it
* worked" and "it worked on the host we wanted" are different facts, and the
* gap between them is ~110 seconds of waiting. The page shows both. */
answer.via = { host: bridge.state.host, fellBack: bridge.state.fellBack, tried: bridge.state.tried };
return answer;
}
/* Installed once per template. Kept out of the function above so the Python is
* one readable block rather than a string with holes in it. */
const TEMPLATE_SETUP = String.raw`
import io as _io, json as _json, sys as _sys, ast as _ast, traceback as _tb
import pandas as _pd, duckdb as _duckdb
if "_tpl" not in globals():
# One session per page, not one per worker.
#
# This is a SharedWorker: every template page in the browser is served by
# this one Pyodide. _tpl used to be a single dict, so opening a second
# template reset the namespace, the connection and the table map underneath
# the first — the second page's prepare wiped the first page's variables
# mid-run. Two tabs produced "NameError: name 'graph' is not defined" in
# *both*, and a DuckDB "different configuration" error when the timing put
# two connections on one file. Neither looked like a tab collision; both
# looked like the template was broken.
#
# Sessions are keyed by page. _tpl still names the active one, so every
# helper below reads it unchanged; _tpl_activate swaps which session that
# is, and does it inside the same call that runs a cell so no message can
# land between the two.
_tpl_sessions = {}
_tpl_active = None
_tpl = {"con": None, "tables": {}, "ns": {}, "written": []}
_tpl_real_read_csv = _pd.read_csv
def _tpl_read_csv(path, *a, **kw):
key = str(path).replace(chr(92), "/")
base = key.rsplit("/", 1)[-1]
table = _tpl["tables"].get(key) or _tpl["tables"].get(base)
if table is None or _tpl["con"] is None:
# Not one of ours. Let pandas fail in its own words rather than
# ours -- a template reading something we never extracted should
# say "no such file", not raise from inside a shim.
return _tpl_real_read_csv(path, *a, **kw)
return _tpl_frame(table, *a, **kw)
def _tpl_frame(table, *a, **kw):
"""Hand back what read_csv would have produced, not what DuckDB knows.
DuckDB's sniffer is better, and that is the problem: it parses
"1985-03-12" into datetime64 and a nullable integer into masked Int64,
where read_csv gives a string and float64. The templates were written
against read_csv's answers. So the database carries bytes and pandas
does the typing -- verified column-for-column against the original CSVs.
Do not "improve" this by returning .df() directly.
"""
cols = [r[0] for r in _tpl["con"].execute('DESCRIBE "%s"' % table).fetchall()]
sel = ", ".join('CAST("%s" AS VARCHAR) AS "%s"' % (c, c) for c in cols)
text = _tpl["con"].execute('SELECT %s FROM "%s"' % (sel, table)).df()
return _tpl_real_read_csv(_io.StringIO(text.to_csv(index=False)), *a, **kw)
_pd.read_csv = _tpl_read_csv
def _tpl_render_frame(frame):
head = frame.head(50)
return {"kind": "frame", "columns": [str(c) for c in head.columns],
"rows": _json.loads(head.to_json(orient="values", default_handler=str)),
"total": int(len(frame))}
def _tpl_render(value):
"""A cell's trailing expression, the way a notebook shows it."""
if value is None:
return None
if isinstance(value, _pd.DataFrame):
return _tpl_render_frame(value)
# A PyRel Query carries no __repr__ of its own -- it inherits object's,
# so a cell ending in one reads "<pyrel_duckdb.query.Query object at
# 0x...>". That is not a browser artifact; the same cell shows the same
# string in Jupyter, which is why so many of these notebooks end on it.
# The rows are one call away, and someone who ended a cell with a query
# wanted the answer, not the address of the object that would give it.
if hasattr(value, "to_df"):
try:
out = value.to_df()
if isinstance(out, _pd.DataFrame):
return _tpl_render_frame(out)
except Exception as exc:
# Evaluating is the risk this takes; say so rather than falling
# back to the same opaque repr and looking like nothing tried.
return {"kind": "text",
"text": "%s could not be evaluated: %s" % (type(value).__name__, exc)}
return {"kind": "text", "text": repr(value)[:4000]}
def _tpl_run(src):
"""Run one cell: stdout, the trailing expression, or the error.
Split into "everything but the last statement" and "the last one" so a
cell ending in an expression shows its value, which is most of what
makes a notebook readable. A cell ending in an assignment shows nothing,
exactly as it would in Jupyter.
"""
buf = _io.StringIO()
old = _sys.stdout
_sys.stdout = buf
result, error = None, None
try:
tree = _ast.parse(src)
body, tail = tree.body[:-1], tree.body[-1:] if tree.body else []
if body:
exec(compile(_ast.Module(body=body, type_ignores=[]), "<cell>", "exec"), _tpl["ns"])
if tail:
node = tail[0]
if isinstance(node, _ast.Expr):
value = eval(compile(_ast.Expression(node.value), "<cell>", "eval"), _tpl["ns"])
result = _tpl_render(value)
else:
exec(compile(_ast.Module(body=tail, type_ignores=[]), "<cell>", "exec"), _tpl["ns"])
except Exception:
# The last frames only. A template traceback runs through pandas and
# pyrel internals, and the useful line is the last one.
lines = _tb.format_exc().strip().splitlines()
error = chr(10).join(lines[-3:])[:1200]
finally:
_sys.stdout = old
return _json.dumps({"stdout": buf.getvalue()[:8000], "result": result, "error": error},
default=str)
def _tpl_materialise():
"""Write each table back out as the CSV file the notebook expects to find.
The read_csv shim above only intercepts pandas. Templates do not all read
their data through pandas: transaction-screening-local hands the path
straight to DuckDB --
model.con.execute("CREATE TABLE txn AS SELECT * FROM read_csv_auto('data/transactions.csv')")
-- which looks in the filesystem, finds nothing, and fails with "No files
found that match the pattern". That was our gap, not the template's bug, and
it would have been reported on the page as the template being broken.
So put the bytes where any reader will find them. This is the general fix
and the shim is now the belt to its braces: whatever a template uses to read
a CSV -- pandas, DuckDB, csv.reader -- there is a real file there, holding
the same text the original did.
Written to "data/<name>" as well as "<name>": every notebook in the corpus
resolves its data through DATA_DIR = Path("data"), and the extractor records
only the basename.
"""
import os as _os
# Clear what a previous template left behind. The browser gives each page a
# fresh worker, but run_templates.mjs drives all 49 through one Pyodide, and
# several of them read a file called "transactions.csv". Without this, one
# template can quietly be served another's data -- and pass, or fail, for a
# reason that has nothing to do with it.
for path in _tpl.get("written", []):
try:
_os.remove(path)
except OSError:
pass
_tpl["written"] = []
if _tpl["con"] is None:
return
for table in set(_tpl["tables"].values()):
cols = [r[0] for r in _tpl["con"].execute('DESCRIBE "%s"' % table).fetchall()]
sel = ", ".join('CAST("%s" AS VARCHAR) AS "%s"' % (c, c) for c in cols)
text = _tpl["con"].execute('SELECT %s FROM "%s"' % (sel, table)).df().to_csv(index=False)
for name in [n for n, t in _tpl["tables"].items() if t == table]:
for path in (name, "data/" + name.rsplit("/", 1)[-1]):
_os.makedirs(_os.path.dirname(path) or ".", exist_ok=True)
with open(path, "w", newline="") as fh:
fh.write(text)
_tpl["written"].append(path)
def _tpl_activate(sid):
"""Make this page's session the one every helper above reads.
Switching also rewrites that session's CSVs. Two templates can name a file
the same thing — several in the corpus read "transactions.csv" — and the
emscripten filesystem has one path for it, so whichever session prepared
last owns the bytes. Re-materialising on the way in means a cell always
reads its own template's data rather than whatever the other tab left.
"""
global _tpl, _tpl_active
sess = _tpl_sessions.get(sid)
if sess is None:
raise RuntimeError("no template session %r (have: %s) — reload the page"
% (sid, sorted(_tpl_sessions)))
switched = _tpl_active != sid
_tpl = sess
_tpl_active = sid
if switched:
_tpl_materialise()
def _tpl_open(sid):
"""Start (or restart) a session for one page."""
global _tpl, _tpl_active
old = _tpl_sessions.get(sid)
if old is not None and old.get("con") is not None:
# Close before the caller writes the database file again: a connection
# left open over a rewritten file is how the "different configuration"
# error appears, and it depends on refcount timing, hence "sometimes".
try:
old["con"].close()
except Exception:
pass
_tpl = {"con": _duckdb.connect(_tpl_db, read_only=True) if _tpl_db else None,
"tables": dict(_tpl_map), "ns": {"__name__": "__template__"}, "written": []}
_tpl_sessions[sid] = _tpl
_tpl_active = sid
_tpl_materialise()
_tpl_open(_tpl_sid)
`;
/* ---- templates ---------------------------------------------------------
*
* Swan's ~52 templates run here, one notebook cell at a time. This is the one
* place the worker executes code it did not shape: `pyrel` compiles a PyRel
* expression in a sandbox, while a template cell is ordinary Python that builds
* its own Model, its own DuckDB connection and its own solver.
*
* That is a real capability, so it is worth being plain about the boundary. The
* code comes from data/templates/, extracted from a swan checkout at build time
* and committed — it is not user input and there is no path from the page to
* arbitrary source. What a template can reach is what any script in this tab
* can reach, which is the tab.
*
* Cells share one namespace, in order, because that is what a notebook means:
* cell 3 uses the model cell 1 built. A fresh namespace per template keeps one
* from leaking into the next — and, since this worker is shared, per *page*
* rather than per worker: see the session comment in TEMPLATE_SETUP.
*/
const templateStates = new Map(); // port -> {sid, slug, loaded, cells}
let nextSessionId = 0;
/* One template operation at a time, across every page.
*
* Arguments reach Python through `pyodide.globals.set`, and runPythonAsync does
* not run the body at call time — it returns a promise and the code executes
* later. So four pages preparing at once each set `_tpl_sid` first and every
* body then read the *last* value written: all four tabs claimed one session
* id, and three of them addressed a session that had never been opened. The
* cell source travels the same way, so the same race would have run one page's
* code in another page's namespace.
*
* Interpolating the id into the source would fix the id and not the source,
* which is a template's own code and must never be spliced into a string. A
* queue fixes both: set globals and run inside a section nothing else can
* enter. Python is single-threaded here regardless, so this costs no
* throughput — it only stops the arguments from being overwritten in flight. */
let templateQueue = Promise.resolve();
function serializeTemplateOp(fn) {
const done = templateQueue.then(fn, fn);
// The chain must survive a rejection, or one failed cell wedges every page.
templateQueue = done.then(() => {}, () => {});
return done;
}
/** This page's session, created on its first prepare. */
function sessionFor(port) {
let state = templateStates.get(port);
if (!state) {
/* Bound the worker's memory without guessing which pages are alive: a
* session holds a model and an open connection, and a browser left open
* across a browsing session can visit a lot of templates. Map iterates in
* insertion order, so the oldest goes — and it is the oldest *page*, which
* is the one least likely to still be watched. */
while (templateStates.size >= MAX_SESSIONS) {
dropTemplateSession(templateStates.keys().next().value);
}
state = { sid: `s${nextSessionId += 1}`, slug: null, loaded: new Set(), cells: 0 };
templateStates.set(port, state);
}
return state;
}
/* Release a page's session. Without this every visited template leaves a
* namespace holding a model and an open DuckDB connection, in a worker that
* outlives the pages by design.
*
* Called when a page says it is going (templateClose, sent on pagehide) and
* when the number of live sessions passes MAX_SESSIONS. Never inferred from a
* failed postMessage — see the comment in broadcast(). */
const MAX_SESSIONS = 8;
function dropTemplateSession(port) {
const state = templateStates.get(port);
if (!state) return;
templateStates.delete(port);
if (!pyReady) return;
try {
pyodide.globals.set('_tpl_sid', state.sid);
pyodide.runPython(`
_gone = _tpl_sessions.pop(_tpl_sid, None)
if _gone is not None and _gone.get("con") is not None:
try:
_gone["con"].close()
except Exception:
pass
if _tpl_active == _tpl_sid:
_tpl_active = None
del _gone
`);
} catch { /* the worker outlives this; a failed cleanup is not worth raising */ }
}
async function templatePrepare(port, { slug, database, tables, packages }) {
needsPython();
const state = sessionFor(port);
/* Extra wheels, only when a template asks. Four use networkx for reachability
* and centrality; loading it for the other 45 is 2 MB nobody reads.
* Tracked per page, but loadPackage itself is global and idempotent. */
for (const name of packages || []) {
if (state.loaded.has(name)) continue;
progress(`loading ${name}…`);
await pyodide.loadPackage(name, { messageCallback: () => {}, errorCallback: () => {} });
}
let bytes = null;
if (database) {
progress('loading the template data…');
/* Resolved against the site root, not the worker. A bare relative path in a
* Worker resolves against the *worker's* URL — src/engine/ — so this asked
* for src/engine/data/templates/diet.duckdb and got a 404 that read as
* missing data rather than a wrong base. BASE points into web/swan-wasm/,
* hence the two levels up. */
const url = new URL(`../../data/templates/${database}`, BASE);
const res = await fetch(url);
if (!res.ok) throw new Error(`could not fetch ${url.pathname}: HTTP ${res.status}`);
bytes = new Uint8Array(await res.arrayBuffer());
pyodide.FS.writeFile(`/${database}`, bytes);
}
/* Set and run with no await in between. Another page's message can only be
* delivered at an await, so globals cannot be overwritten under this call —
* which is the whole reason activation and execution are fused below too. */
pyodide.globals.set('_tpl_db', database ? `/${database}` : null);
pyodide.globals.set('_tpl_map', pyodide.toPy(tables || {}));
pyodide.globals.set('_tpl_sid', state.sid);
await pyodide.runPythonAsync(TEMPLATE_SETUP);
for (const name of packages || []) state.loaded.add(name);
state.slug = slug;
state.cells = 0;
return { slug, database: database || null, bytes: bytes ? bytes.length : 0 };
}
async function templateCell(port, { source }) {
needsPython();
const state = templateStates.get(port);
if (!state || !state.slug) throw new Error('no template prepared — send templatePrepare first');
pyodide.globals.set('_tpl_src', source);
pyodide.globals.set('_tpl_sid', state.sid);
// One call, so no other page can activate its own session between the two.
const out = await pyodide.runPythonAsync('_tpl_activate(_tpl_sid)\n_tpl_run(_tpl_src)');
state.cells += 1;
return JSON.parse(out);
}
/* ---- the model a template just built, as a graph -----------------------
*
* The Derivation Ladder reads a {nodes, edges} document with three tiers. For
* TPC-DS that document is `data/tpcds.json`, produced offline by three scripts
* over a checked-out model directory. A template page has no checkout — but it
* has the model itself, standing in this worker's namespace, built by the cells
* the reader just ran and carrying whatever they edited before running them.
*
* tools/live_graph.py collapses those three stages into one call against a live
* Model. It is fetched rather than inlined: it is ~100 KB of Python across four
* modules, it is the same code the offline path runs (so the two cannot drift),
* and no template page pays for it unless someone opens the model view.
*/
const GRAPH_MODULES = ['extract_duckdb.py', 'extract_swan.py', 'build_graph.py', 'live_graph.py'];
let graphModulesReady = null;
async function ensureGraphModules() {
if (graphModulesReady) return graphModulesReady;
graphModulesReady = (async () => {
progress('loading the model reader…');
pyodide.FS.mkdirTree('/eh/graph');
for (const name of GRAPH_MODULES) {
// Same two-levels-up as the template database: a bare relative path in a
// worker resolves against src/engine/, not the site root.
const url = new URL(`../../tools/${name}`, BASE);
const res = await fetch(url);
if (!res.ok) throw new Error(`could not fetch ${url.pathname}: HTTP ${res.status}`);
pyodide.FS.writeFile(`/eh/graph/${name}`, await res.text());
}
await pyodide.runPythonAsync(`
import sys
if "/eh/graph" not in sys.path:
sys.path.insert(0, "/eh/graph")
`);
})();
return graphModulesReady;
}
async function templateGraph(port) {
needsPython();
const state = templateStates.get(port);
if (!state || !state.slug) throw new Error('no template prepared — send templatePrepare first');
await ensureGraphModules();
progress('reading the model…');
pyodide.globals.set('_tpl_sid', state.sid);
const out = await pyodide.runPythonAsync(`
_tpl_activate(_tpl_sid)
import json as _json
from pyrel_duckdb import Model as _Model
from live_graph import graph_for_model as _graph_for_model
# A notebook usually names it \`model\`; prefer that over scanning, so a template
# that builds a throwaway Model first still reports the one it went on to use.
_cands = [v for v in _tpl["ns"].values() if isinstance(v, _Model)]
_m = _tpl["ns"].get("model") if isinstance(_tpl["ns"].get("model"), _Model) else (_cands[-1] if _cands else None)
_json.dumps({"graph": _graph_for_model(_m) if _m is not None else None,
"models": len(_cands)}, default=str)
`);
return JSON.parse(out);
}
async function runPyrel(code) {
// The same sandbox the sidecar uses. Hand-written and generated code are
// indistinguishable by the time they reach here, so neither gets more trust.
pyodide.globals.set('_eh_code', code);
const out = await pyodide.runPythonAsync(`
if _EH["model"] is None:
raise RuntimeError("no model loaded — send a 'model' message first")
_res = execute_pyrel_code(_EH["model"], _eh_code)
json.dumps(_res if isinstance(_res, list) else str(_res), default=str)
`);
return JSON.parse(out);
}
const needsDb = () => { if (!dbReady) throw new Error('the worker has no database yet — send init first'); };
/* What a newly-connected page needs to know about work already done. Kept
* beside the caches it reads so the two cannot drift. */
let pythonInfo = null;
let datasetInfo = null;
let rulesInfo = null;
const liveState = () => ({
pyrelStarted: pyReady,
python: pythonInfo,
dataset: datasetInfo,
rules: rulesInfo,
});
const needsPython = () => {
needsDb();
if (!pyReady) throw new Error('the Python front-end is not started — send python first');
};
async function handle(port, e) {
const { id, type, payload } = e.data || {};
try {
let result;
switch (type) {
case 'init':
/* Report what is already up, not just what the manifest says. A page
* attaching to a SharedWorker that a previous page already loaded must
* be able to tell -- otherwise it offers "Load it" for a model that is
* sitting right there, and clicking it rebuilds what already exists. */
result = { ...await initDb(), ...liveState() };
break;
case 'python':
needsDb();
/* Idempotent, because "has someone else already done this?" is now a
* real question. initPython() is ~100 MB and rebinds _EH; running it a
* second time for a second page would discard a loaded model. */
result = pyReady ? pythonInfo : (pythonInfo = await initPython());
break;
case 'sql':
needsDb();
result = JSON.parse(execSql(payload, null));
break;
case 'dataset':
needsPython();
// Same reasoning as `python`: a second page must reattach, not reload.
result = datasetInfo || (datasetInfo = await loadDataset());
break;
case 'model':
needsPython();
result = JSON.parse(await loadModel(payload));
break;
case 'templatePrepare':
result = await serializeTemplateOp(() => templatePrepare(port, payload));
break;
case 'templateCell':
result = await serializeTemplateOp(() => templateCell(port, payload));
break;
case 'templateGraph':
result = await serializeTemplateOp(() => templateGraph(port));
break;
case 'templateClose':
// The page is going. Nothing to report back, and no reason to fail if
// it never prepared anything.
await serializeTemplateOp(() => dropTemplateSession(port));
result = { closed: true };
break;
case 'reloadModel':
needsPython();
result = await reloadModel();
break;
case 'reloadData':
/* Forget the cached database and stand down, so the page that asked can
* reload into a worker with nothing behind it.
*
* Not a rebuild in place. Pulling the database out from under a live
* model means closing its connections, deleting files inside Pyodide and
* re-running the load with half the engine still warm -- a lot of moving
* parts for the one action someone takes when they already think
* something is wrong. Standing down cannot leave a half-state, and
* Pyodide comes back from the HTTP cache, so what this really costs is
* the boot rather than the ~100 MB.
*
* If the clear throws -- private browsing, a locked store -- it throws
* before the close is scheduled, so the worker stays up and the page
* hears about it. Reloading into the same cached database while
* reporting success is the one outcome worth avoiding here.
*
* The close is deferred because the reply still has to be posted below.
* It has to happen at all because a SharedWorker outlives one page's
* reload whenever another tab is holding it. */
await idbClear(await idb());
setTimeout(() => self.close(), 250);
result = { ok: true };
break;
case 'pyrel':
needsPython();
result = { code: payload, rows: await runPyrel(payload) };
break;
case 'ask':
needsPython();
result = await runAsk(payload || {});
break;
case 'schema':
needsDb();
// With a model loaded, the model's schema is the useful answer; without
// one, the tables are all there is to describe.
/* `model` carries the same stats the sidecar's /schema does, not a
* boolean. The page renders `${s.model.concepts} concepts`, and against
* `true` that read "undefined concepts" — the panel reporting on itself
* in a way no engine check would catch, since the call succeeded. */
result = pyReady && pyodide.runPython('_EH["model"] is not None')
? {
schema: pyodide.runPython('serialize_model_schema(_EH["model"])'),
model: JSON.parse(pyodide.runPython(`json.dumps({
"name": getattr(_EH["model"], "name", "model"),
"concepts": len(_EH["model"]._concepts),
})`)),
}
/* No Python yet means no database yet — it lives inside Pyodide now,
* so there are no tables to describe rather than an error to report.
* Querying information_schema here threw, and horizon.js calls
* schema() straight after ready(), so every visit opened on a red
* engine-failure banner before touching anything. It survived the
* headless checks because those call startPyrel() first. */
: !pyReady
? { schema: [], model: false }
: { schema: JSON.parse(execSql(
'SELECT table_name, column_name, data_type FROM information_schema.columns '
+ 'ORDER BY table_name, ordinal_position')).rows, model: false };
break;
default:
throw new Error(`unknown message: ${type}`);
}
port.postMessage({ id, ok: true, result });
} catch (err) {
port.postMessage({ id, ok: false, ...describeFailure(err) });
}
}
/* Turn a Pyodide failure into something a person can read.
*
* Pyodide sets a PythonError's `message` to the *entire formatted traceback*, so
* posting `String(err.message)` put frames from `_pyodide/_base.py` in front of
* the user. A question that failed for the ordinary reason -- the model wrote a
* query that would not run -- was reported as what looks like a crash in the
* engine, and the actual explanation was on line 30 of it.
*
* Two recoveries, in order:
*
* the JSON payload runAsk raises, which carries the failed program and the
* translated database error -- the whole point of building it
*
* failing that, the last non-empty line, which in a Python traceback is the
* exception itself. Never better than the payload, always better than frames.
*/
function describeFailure(err) {
const raw = String(err && err.message || err || 'unknown failure');
// JSON.stringify escapes newlines, so the payload is always one line. Find it
// by its shape rather than by position: `raise ... from` and chained handlers
// both append frames after it.
for (const line of raw.split('\n').reverse()) {
const at = line.indexOf('{"error"');
if (at < 0) continue;
try {
const payload = JSON.parse(line.slice(at));
if (payload && typeof payload.error === 'string') {
return { error: payload.error, code: payload.code || null, detail: payload.detail || null };
}
} catch { /* a line that merely looks like the payload */ }
}
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean);
const last = lines[lines.length - 1];
// Only collapse to the last line when this really is a traceback; an ordinary
// Error's message is already the thing to show, and may be several lines.
return { error: /Traceback \(most recent call last\)/.test(raw) && last ? last : raw };
}
/* One connection per page. Request ids are per-port counters, so two pages both
* starting at 1 is fine as long as a reply goes back to the port it came from --
* which is why handle() takes the port rather than reading a global. */
self.onconnect = (e) => {
const port = e.ports[0];
ports.add(port);
port.onmessage = (ev) => handle(port, ev);
port.start();
};