Implement AXIS parity UI and execution baseline
This commit is contained in:
@@ -2,6 +2,15 @@ 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) {
|
||||
@@ -10,6 +19,11 @@ function finiteJson(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;
|
||||
@@ -17,7 +31,18 @@ export class PythonBridge {
|
||||
this.maxCalls = maxCalls;
|
||||
this.pyodide = null;
|
||||
this.initialized = false;
|
||||
this.state = { imports: [], calls: [], errors: [], capabilities: [], tick: 0 };
|
||||
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() {
|
||||
@@ -30,6 +55,7 @@ export class PythonBridge {
|
||||
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,
|
||||
};
|
||||
@@ -48,7 +74,7 @@ export class PythonBridge {
|
||||
}
|
||||
|
||||
importModule(module) {
|
||||
const symbols = manifest.virtual_modules[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() };
|
||||
@@ -62,16 +88,145 @@ export class PythonBridge {
|
||||
}
|
||||
|
||||
invoke(module, symbol, args = []) {
|
||||
const symbols = manifest.virtual_modules[module];
|
||||
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");
|
||||
if (!this.state.imports.includes(module)) this.state.imports.push(module);
|
||||
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));
|
||||
@@ -90,7 +245,12 @@ export class PythonBridge {
|
||||
return { status: extra.status ?? "blocked", code, message_key, retryable: false, ...extra, state_hash: this.hash() };
|
||||
}
|
||||
|
||||
setTick(tick) { if (Number.isSafeInteger(tick) && tick >= 0) this.state.tick = tick; }
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user