Files
linuxcncWebWASM/web/worker/python-bridge.mjs

265 lines
15 KiB
JavaScript

import manifest from "../../python-runtime/manifest.json" with { type: "json" };
import { fnv1a64 } from "./replay.mjs";
export const PYTHON_BRIDGE_SCHEMA = "python-worker-bridge.v1";
const COMPAT_MODULES = Object.freeze({
tool: ["get", "set"],
parameter: ["get", "set"],
parameters: ["get", "set"],
workspace: ["read", "write", "list", "stat", "hash"],
"virtual-clock": ["now", "tick", "sleep"],
});
const WORKSPACE_MAX_FILES = 256;
const WORKSPACE_MAX_BYTES = 16 * 1024 * 1024;
function clone(value) { return structuredClone(value); }
function finiteJson(value) {
if (value === null || typeof value !== "object") return true;
if (typeof value === "number") return Number.isFinite(value);
return Array.isArray(value) ? value.every(finiteJson) : Object.values(value).every(finiteJson);
}
function virtualPath(value) {
if (typeof value !== "string" || !value.startsWith("/workspace/") || value.includes("..") || value.includes("\\") || value.length > 1024) return null;
return value;
}
export class PythonBridge {
constructor({ loadPyodide = globalThis.loadPyodide, maxSourceBytes = 65536, maxCalls = 256 } = {}) {
this.loadPyodide = loadPyodide;
this.maxSourceBytes = maxSourceBytes;
this.maxCalls = maxCalls;
this.pyodide = null;
this.initialized = false;
this.state = {
imports: [], calls: [], errors: [], capabilities: [], tick: 0,
api: {
linuxcnc: { mode: "MANUAL", estop: false, task_state: "READY", last_command: null, ini: null },
gstat: { state: "READY", task: 0, motion: 0 },
hal: { components: {}, pins: {}, params: {}, signals: {}, threads: {} },
ini: { sections: {}, values: {} },
tools: {},
parameters: {},
workspace: { files: {}, bytes: 0 },
},
};
}
capabilities() {
return {
schema: PYTHON_BRIDGE_SCHEMA,
runtime: manifest.runtime,
version: manifest.version,
python_version: manifest.python_version,
abi_version: manifest.abi_version,
platform: manifest.platform,
worker_only: manifest.worker_only,
virtual_modules: clone(manifest.virtual_modules),
compatibility_modules: clone(COMPAT_MODULES),
denied_capabilities: [...manifest.denied_capabilities],
initialized: this.initialized,
};
}
async initialize() {
if (this.initialized) return { status: "verified", capabilities: this.capabilities() };
if (typeof this.loadPyodide !== "function") return this.fail("python_runtime_unavailable", "python.runtime_unavailable");
try {
this.pyodide = await this.loadPyodide({ indexURL: manifest.index_url });
this.initialized = true;
return { status: "verified", capabilities: this.capabilities() };
} catch (error) {
return this.fail("python_runtime_init", String(error?.message ?? error));
}
}
importModule(module) {
const symbols = manifest.virtual_modules[module] ?? COMPAT_MODULES[module];
if (!symbols) return this.fail("python_unsupported_module", "python.unsupported_module", { module });
if (!this.state.imports.includes(module)) this.state.imports.push(module);
return { status: "verified", module, symbols: [...symbols], state_hash: this.hash() };
}
checkCapability(capability) {
if (manifest.denied_capabilities.includes(capability)) return this.fail("real_hardware_excluded", "python.host_capability_denied", { capability, status: "REAL-EXCLUDED" });
if (typeof capability !== "string" || capability.length === 0) return this.fail("python_capability_invalid", "python.capability_invalid");
if (!this.state.capabilities.includes(capability)) this.state.capabilities.push(capability);
return { status: "verified", capability, state_hash: this.hash() };
}
invoke(module, symbol, args = []) {
const symbols = manifest.virtual_modules[module] ?? COMPAT_MODULES[module];
if (!symbols || !symbols.includes(symbol)) return this.fail("python_symbol_unsupported", "python.symbol_unsupported", { module, symbol });
if (!Array.isArray(args) || !finiteJson(args)) return this.fail("python_argument_invalid", "python.argument_invalid");
if (this.state.calls.length >= this.maxCalls) return this.fail("python_call_limit", "python.call_limit");
const call = { module, symbol, args: clone(args), tick: this.state.tick };
this.state.calls.push(call);
if (!this.state.imports.includes(module)) this.state.imports.push(module);
const apiResult = this.apiCall(module, symbol, args);
if (apiResult) return apiResult;
return { status: "verified", value: { module, symbol, args: clone(args) }, state_hash: this.hash() };
}
apiCall(module, symbol, args = []) {
const api = this.state.api;
const stringArg = (value) => typeof value === "string" && value.length > 0 ? value : null;
const finiteArg = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
if (module === "linuxcnc") {
if (symbol === "command") {
const command = stringArg(args[0]);
if (!command || !["mode", "estop", "abort", "run", "pause", "resume"].includes(command)) return this.fail("python_linuxcnc_command", "python.linuxcnc_command_invalid");
api.linuxcnc.last_command = command;
if (command === "mode" && ["MANUAL", "MDI", "AUTO"].includes(args[1])) api.linuxcnc.mode = args[1];
else if (command === "mode") return this.fail("python_linuxcnc_mode", "python.linuxcnc_mode_invalid");
else if (command === "estop") api.linuxcnc.estop = Boolean(args[1]);
else if (command === "abort") api.linuxcnc.task_state = "STOPPED";
else if (command === "run") api.linuxcnc.task_state = "RUNNING";
else if (command === "pause") api.linuxcnc.task_state = "PAUSED";
else if (command === "resume") api.linuxcnc.task_state = "RUNNING";
api.gstat.state = api.linuxcnc.task_state;
return { status: "verified", value: clone(api.linuxcnc), state_hash: this.hash() };
}
if (symbol === "stat") return { status: "verified", value: clone(api.linuxcnc), state_hash: this.hash() };
if (symbol === "ini") return { status: "verified", value: clone(api.ini), state_hash: this.hash() };
if (symbol === "error") return { status: "verified", value: { code: api.linuxcnc.estop ? "ESTOP" : null }, state_hash: this.hash() };
}
if (module === "gstat") {
if (!["state", "task", "motion"].includes(symbol)) return this.fail("python_gstat_symbol", "python.symbol_unsupported");
return { status: "verified", value: symbol === "state" ? clone(api.gstat) : api.gstat[symbol], state_hash: this.hash() };
}
if (module === "hal") {
const name = stringArg(args[0]);
if (symbol === "component") {
if (!name) return this.fail("python_hal_component", "python.hal_name_invalid");
api.hal.components[name] = { name, ready: true };
return { status: "verified", value: clone(api.hal.components[name]), state_hash: this.hash() };
}
if (["pin", "param", "signal"].includes(symbol)) {
if (!name) return this.fail("python_hal_name", "python.hal_name_invalid");
const collection = symbol === "param" ? api.hal.params : symbol === "signal" ? api.hal.signals : api.hal.pins;
const value = args.length > 1 ? args[1] : (collection[name]?.value ?? 0);
if (value !== undefined && value !== null && !finiteJson(value)) return this.fail("python_hal_value", "python.argument_invalid");
collection[name] = { name, kind: symbol, value };
return { status: "verified", value: clone(collection[name]), state_hash: this.hash() };
}
if (symbol === "thread") {
if (!name || !Number.isSafeInteger(args[1]) || args[1] < 1) return this.fail("python_hal_thread", "python.hal_thread_invalid");
api.hal.threads[name] = { name, period_ticks: args[1] };
return { status: "verified", value: clone(api.hal.threads[name]), state_hash: this.hash() };
}
}
if (module === "workspace") {
const path = virtualPath(args[0] ?? ((symbol === "list" || symbol === "hash") ? "/workspace/" : undefined));
if (!path) return this.fail("python_workspace_path", "python.workspace_path_invalid");
if (symbol === "read") return { status: "verified", value: api.workspace.files[path] ?? null, state_hash: this.hash() };
if (symbol === "write") {
if (typeof args[1] !== "string") return this.fail("python_workspace_data", "python.workspace_data_invalid");
const oldBytes = api.workspace.files[path] ? new TextEncoder().encode(api.workspace.files[path]).byteLength : 0;
const bytes = new TextEncoder().encode(args[1]).byteLength;
if (!api.workspace.files[path] && Object.keys(api.workspace.files).length >= WORKSPACE_MAX_FILES) return this.fail("python_workspace_file_limit", "python.workspace_file_limit");
if (api.workspace.bytes - oldBytes + bytes > WORKSPACE_MAX_BYTES) return this.fail("python_workspace_quota", "python.workspace_quota");
api.workspace.files[path] = args[1]; api.workspace.bytes = api.workspace.bytes - oldBytes + bytes;
return { status: "verified", value: { path, bytes }, state_hash: this.hash() };
}
if (symbol === "list") {
const prefix = args[1] === undefined ? path : virtualPath(args[1]);
if (!prefix) return this.fail("python_workspace_path", "python.workspace_path_invalid");
return { status: "verified", value: Object.keys(api.workspace.files).filter((item) => item.startsWith(prefix)).sort(), state_hash: this.hash() };
}
if (symbol === "stat") {
const data = api.workspace.files[path];
return { status: "verified", value: data === undefined ? null : { path, bytes: new TextEncoder().encode(data).byteLength }, state_hash: this.hash() };
}
if (symbol === "hash") return { status: "verified", value: fnv1a64(api.workspace), state_hash: this.hash() };
}
if (module === "virtual-clock" || module === "clock") {
if (symbol === "now" || symbol === "tick") return { status: "verified", value: this.virtualClock(), state_hash: this.hash() };
if (symbol === "sleep") {
const ticks = args[0];
if (!Number.isSafeInteger(ticks) || ticks < 0 || ticks > 1000000) return this.fail("python_clock_argument", "python.clock_argument_invalid");
return this.setTick(this.state.tick + ticks);
}
}
if (module === "INI" || module === "ini") {
const section = stringArg(args[0]);
const key = stringArg(args[1]);
if (symbol === "sections") return { status: "verified", value: Object.keys(api.ini.sections).sort(), state_hash: this.hash() };
if (symbol === "find" || symbol === "findall") {
if (!section || !key) return this.fail("python_ini_arguments", "python.ini_arguments_invalid");
const values = api.ini.values[`${section}.${key}`] ?? [];
return { status: "verified", value: symbol === "find" ? (values[0] ?? null) : [...values], state_hash: this.hash() };
}
}
if (module === "tool" || module === "tools") {
const tool = args[0];
if (!Number.isSafeInteger(tool) || tool < 0 || tool > 1001) return this.fail("python_tool_number", "python.tool_number_invalid");
if (symbol === "set") {
if (!finiteJson(args[1])) return this.fail("python_tool_value", "python.argument_invalid");
api.tools[String(tool)] = clone(args[1]);
}
return { status: "verified", value: clone(api.tools[String(tool)] ?? null), state_hash: this.hash() };
}
if (module === "parameter" || module === "parameters") {
const parameter = args[0];
if (!(Number.isSafeInteger(parameter) && parameter >= 1 && parameter <= 5399) && !(typeof parameter === "string" && parameter.length > 0)) return this.fail("python_parameter_name", "python.parameter_invalid");
if (symbol === "set") {
if (!finiteArg(args[1])) return this.fail("python_parameter_value", "python.argument_invalid");
api.parameters[String(parameter)] = args[1];
}
return { status: "verified", value: api.parameters[String(parameter)] ?? 0, state_hash: this.hash() };
}
return null;
}
configureIni(sections = {}) {
if (!sections || typeof sections !== "object" || Array.isArray(sections)) return this.fail("python_ini_shape", "python.ini_shape_invalid");
this.state.api.ini.sections = {};
this.state.api.ini.values = {};
for (const [section, values] of Object.entries(sections)) {
if (typeof section !== "string" || !values || typeof values !== "object" || Array.isArray(values)) return this.fail("python_ini_shape", "python.ini_shape_invalid");
this.state.api.ini.sections[section] = true;
for (const [key, value] of Object.entries(values)) {
if (!finiteJson(value)) return this.fail("python_ini_value", "python.argument_invalid");
this.state.api.ini.values[`${section}.${key}`] = Array.isArray(value) ? clone(value) : [value];
}
}
this.state.api.linuxcnc.ini = clone(this.state.api.ini);
return { status: "verified", state_hash: this.hash() };
}
async execute(source) {
if (typeof source !== "string" || new TextEncoder().encode(source).length > this.maxSourceBytes) return this.fail("python_source_limit", "python.source_limit");
const denied = manifest.denied_capabilities.find((capability) => new RegExp(`(?:^|[^A-Za-z0-9_])${capability.replaceAll(".", "\\.")}(?:$|[^A-Za-z0-9_])`).test(source));
if (denied) return this.fail("real_hardware_excluded", "python.host_capability_denied", { capability: denied, status: "REAL-EXCLUDED" });
if (!this.pyodide || typeof this.pyodide.runPythonAsync !== "function") return this.fail("python_runtime_unavailable", "python.runtime_unavailable");
try {
const value = await this.pyodide.runPythonAsync(source);
return { status: "verified", value: value === undefined ? null : value, state_hash: this.hash() };
} catch (error) {
return this.fail("python_exception", "python.exception", { detail: String(error?.message ?? error) });
}
}
fail(code, message_key, extra = {}) {
this.state.errors.push({ code, tick: this.state.tick });
return { status: extra.status ?? "blocked", code, message_key, retryable: false, ...extra, state_hash: this.hash() };
}
setTick(tick) {
if (!Number.isSafeInteger(tick) || tick < 0 || tick < this.state.tick) return this.fail("python_clock_regression", "python.clock_regression");
this.state.tick = tick;
return { status: "verified", clock: this.virtualClock(), state_hash: this.hash() };
}
virtualClock() { return { tick: this.state.tick, seconds: this.state.tick * 0.001, source: "logical_tick" }; }
exportState() { return { schema: PYTHON_BRIDGE_SCHEMA, initialized: this.initialized, state: clone(this.state) }; }
restoreState(value) {
if (!value || value.schema !== PYTHON_BRIDGE_SCHEMA || !value.state) return this.fail("python_state_invalid", "python.state_invalid");
this.initialized = Boolean(value.initialized);
this.state = clone(value.state);
return { status: "verified", state_hash: this.hash() };
}
hash() { return fnv1a64(this.state); }
}
export { manifest };