Implement AXIS parity UI and execution baseline
This commit is contained in:
117
web/workspace/configuration-batch.mjs
Normal file
117
web/workspace/configuration-batch.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
import { fnv1a64 } from "../worker/replay.mjs";
|
||||
|
||||
export const CONFIGURATION_BATCH_SCHEMA = "configuration-startup-batch.v1";
|
||||
const MAX_ENTRIES = 512;
|
||||
const TERMINAL = new Set(["ready", "blocked"]);
|
||||
|
||||
const clone = (value) => structuredClone(value);
|
||||
const text = (value) => typeof value === "string" && value.length > 0;
|
||||
|
||||
function diagnostic(row) {
|
||||
const blockedCode = row.startup_preflight === "blocked_unindexed_resource" || row.resource_status === "unindexed"
|
||||
? "resource_unindexed"
|
||||
: row.startup_preflight === "blocked_unregistered_kinematics"
|
||||
? "kinematics_unregistered"
|
||||
: row.unresolved_resources?.length
|
||||
? "resource_unresolved"
|
||||
: "resource_missing";
|
||||
return {
|
||||
status: "blocked",
|
||||
code: blockedCode,
|
||||
config_id: row.config_id,
|
||||
entrypoint: row.path,
|
||||
module: row.module ?? null,
|
||||
startup_preflight: row.startup_preflight,
|
||||
resource_status: row.resource_status,
|
||||
missing_resources: [...(row.missing_resources ?? [])].sort(),
|
||||
unresolved_resources: [...(row.unresolved_resources ?? [])].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRow(row, index) {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) throw new Error("configuration_batch_row");
|
||||
if (!text(row.config_id) || !text(row.path) || !text(row.startup_preflight) || !text(row.resource_status)) throw new Error("configuration_batch_fields");
|
||||
const terminal = row.startup_preflight === "static_ready" ? "ready" : "blocked";
|
||||
if (!TERMINAL.has(terminal) || row.status !== terminal) throw new Error("configuration_batch_status");
|
||||
if (!Array.isArray(row.missing_resources) || !Array.isArray(row.unresolved_resources)) throw new Error("configuration_batch_diagnostics");
|
||||
return {
|
||||
config_id: row.config_id,
|
||||
path: row.path,
|
||||
module: row.module ?? null,
|
||||
family: row.family ?? null,
|
||||
source_id: row.source_id ?? row.config_id,
|
||||
line: Number.isSafeInteger(row.line) ? row.line : null,
|
||||
status: terminal,
|
||||
startup_preflight: row.startup_preflight,
|
||||
resource_status: row.resource_status,
|
||||
resource_rows: Number.isSafeInteger(row.resource_rows) ? row.resource_rows : 0,
|
||||
missing_resources: [...row.missing_resources].map(String).sort(),
|
||||
unresolved_resources: [...row.unresolved_resources].map(String).sort(),
|
||||
...(terminal === "blocked" ? { diagnostic: diagnostic({ ...row, status: terminal }) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export class ConfigurationBatchRuntime {
|
||||
constructor({ context_id = null } = {}) {
|
||||
this.contextId = context_id;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.state = { schema: CONFIGURATION_BATCH_SCHEMA, context_id: this.contextId, entrypoints: [], counts: { total: 0, ready: 0, blocked: 0, startup_preflight: {}, resource_status: {} }, diagnostics: [], last_error: null };
|
||||
return this.export();
|
||||
}
|
||||
|
||||
export() {
|
||||
const unsigned = clone(this.state);
|
||||
return { ...unsigned, state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
|
||||
hash() { return this.export().state_hash; }
|
||||
|
||||
fail(code, detail = null) {
|
||||
this.state.last_error = code;
|
||||
return { status: "blocked", code, ...(detail ? { detail } : {}), state: this.export() };
|
||||
}
|
||||
|
||||
load(entries, { context_id = this.contextId } = {}) {
|
||||
if (!Array.isArray(entries) || entries.length === 0 || entries.length > MAX_ENTRIES) return this.fail("configuration_batch_capacity");
|
||||
if (context_id !== null && !text(context_id)) return this.fail("configuration_batch_context");
|
||||
const before = clone(this.state);
|
||||
try {
|
||||
const normalized = entries.map(normalizeRow);
|
||||
const ids = new Set();
|
||||
for (const row of normalized) {
|
||||
if (ids.has(row.config_id)) throw new Error("configuration_batch_duplicate");
|
||||
ids.add(row.config_id);
|
||||
}
|
||||
normalized.sort((left, right) => left.config_id.localeCompare(right.config_id));
|
||||
const counts = { total: normalized.length, ready: normalized.filter((row) => row.status === "ready").length, blocked: normalized.filter((row) => row.status === "blocked").length, startup_preflight: {}, resource_status: {} };
|
||||
for (const row of normalized) {
|
||||
counts.startup_preflight[row.startup_preflight] = (counts.startup_preflight[row.startup_preflight] ?? 0) + 1;
|
||||
counts.resource_status[row.resource_status] = (counts.resource_status[row.resource_status] ?? 0) + 1;
|
||||
}
|
||||
this.contextId = context_id;
|
||||
this.state = { schema: CONFIGURATION_BATCH_SCHEMA, context_id: this.contextId, entrypoints: normalized, counts, diagnostics: normalized.filter((row) => row.status === "blocked").map((row) => row.diagnostic), last_error: null };
|
||||
return { status: "verified", command: "CONFIGURATION_BATCH", state: this.export() };
|
||||
} catch (error) {
|
||||
this.state = before;
|
||||
return this.fail(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
restore(value) {
|
||||
if (!value || value.schema !== CONFIGURATION_BATCH_SCHEMA || !Array.isArray(value.entrypoints)) throw new Error("configuration_batch_restore_schema");
|
||||
const unsigned = clone(value); delete unsigned.state_hash;
|
||||
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("configuration_batch_restore_hash");
|
||||
if (unsigned.entrypoints.length > MAX_ENTRIES) throw new Error("configuration_batch_restore_capacity");
|
||||
this.state = unsigned; this.contextId = unsigned.context_id ?? this.contextId;
|
||||
return this.export();
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyConfigurationBatch(entries, options = {}) {
|
||||
return new ConfigurationBatchRuntime(options).load(entries, options);
|
||||
}
|
||||
|
||||
export { MAX_ENTRIES as CONFIGURATION_BATCH_CAPACITY };
|
||||
265
web/workspace/configuration-startup.mjs
Normal file
265
web/workspace/configuration-startup.mjs
Normal file
@@ -0,0 +1,265 @@
|
||||
import { fnv1a64 } from "../worker/replay.mjs";
|
||||
import { WorkspaceRuntime, normalizeWorkspacePath } from "./runtime.mjs";
|
||||
import { parseIni } from "./ini-parser.mjs";
|
||||
import { resolveResources } from "./resource-resolver.mjs";
|
||||
import { preflightHalConfiguration } from "./hal-configuration.mjs";
|
||||
|
||||
export const CONFIGURATION_STARTUP_SCHEMA = "configuration-startup-runtime.v1";
|
||||
export const STARTUP_STATES = Object.freeze([
|
||||
"NEW", "MOUNTED", "PARSED", "RESOLVED", "CORE_CREATED", "HAL_READY", "TASK_READY", "BLOCKED",
|
||||
]);
|
||||
|
||||
export const STARTUP_ERRORS = Object.freeze({
|
||||
invalid_input: "startup_input_invalid",
|
||||
workspace: "startup_workspace_blocked",
|
||||
ini: "startup_ini_blocked",
|
||||
resources: "startup_resources_blocked",
|
||||
core: "startup_core_blocked",
|
||||
hal: "startup_hal_blocked",
|
||||
task: "startup_task_blocked",
|
||||
transition: "startup_transition_invalid",
|
||||
capability: "startup_capability_unsupported",
|
||||
});
|
||||
|
||||
const ALLOWED = Object.freeze({
|
||||
NEW: ["MOUNTED", "BLOCKED"],
|
||||
MOUNTED: ["PARSED", "BLOCKED"],
|
||||
PARSED: ["RESOLVED", "BLOCKED"],
|
||||
RESOLVED: ["CORE_CREATED", "BLOCKED"],
|
||||
CORE_CREATED: ["HAL_READY", "BLOCKED"],
|
||||
HAL_READY: ["TASK_READY", "BLOCKED"],
|
||||
TASK_READY: [],
|
||||
BLOCKED: [],
|
||||
});
|
||||
|
||||
function clone(value) { return structuredClone(value); }
|
||||
function text(value) { return typeof value === "string" && value.length > 0 ? value : null; }
|
||||
|
||||
function coreCall(core, name, ...args) {
|
||||
if (!core) return 0;
|
||||
if (args.some((value) => typeof value === "string") && typeof core.ccall === "function") {
|
||||
return core.ccall(name, "number", args.map((value) => typeof value === "string" ? "string" : "number"), args);
|
||||
}
|
||||
const direct = core[`_${name}`] ?? core[name];
|
||||
if (typeof direct === "function") return direct(...args);
|
||||
if (typeof core.ccall === "function") {
|
||||
const types = args.map((value) => typeof value === "string" ? "string" : "number");
|
||||
return core.ccall(name, "number", types, args);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function diagnostic(code, source = null, line = 0, extra = {}) {
|
||||
return { status: "blocked", code, source, line: Number.isSafeInteger(line) ? line : 0, ...extra };
|
||||
}
|
||||
|
||||
function asWorkspace(value) {
|
||||
if (value && typeof value.read === "function" && typeof value.list === "function") return value;
|
||||
if (value instanceof WorkspaceRuntime) return value;
|
||||
if (value && typeof value === "object" && value.files && typeof value.files === "object") return new WorkspaceRuntime(value);
|
||||
if (value && typeof value === "object") return new WorkspaceRuntime({ files: value });
|
||||
throw new Error(STARTUP_ERRORS.workspace);
|
||||
}
|
||||
|
||||
function halManifest(manifest = []) {
|
||||
return Array.isArray(manifest) ? manifest : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic configuration startup pipeline. A pipeline instance is single-use;
|
||||
* all externally visible data is returned by report() and can be hashed/replayed.
|
||||
*/
|
||||
export class ConfigurationStartupStateMachine {
|
||||
constructor({ workspace = null, files = null, entrypoint = null, variables = {}, maxDepth = 32, core = null, task = null, halManifest: manifest = [], coreCreated = false } = {}) {
|
||||
this.state = "NEW";
|
||||
this.workspace = workspace;
|
||||
this.files = files;
|
||||
this.entrypoint = entrypoint;
|
||||
this.variables = { ...variables };
|
||||
this.maxDepth = maxDepth;
|
||||
this.core = core;
|
||||
this.task = task;
|
||||
this.manifest = manifest;
|
||||
this.coreCreated = coreCreated === true;
|
||||
this.ini = null;
|
||||
this.resources = null;
|
||||
this.hal = null;
|
||||
this.diagnostics = [];
|
||||
this.transitions = [{ from: null, to: "NEW" }];
|
||||
this.coreResult = null;
|
||||
this.stateHash = null;
|
||||
}
|
||||
|
||||
transition(next) {
|
||||
if (!STARTUP_STATES.includes(next) || !ALLOWED[this.state]?.includes(next)) {
|
||||
throw new Error(`${STARTUP_ERRORS.transition}:${this.state}->${next}`);
|
||||
}
|
||||
const from = this.state;
|
||||
this.state = next;
|
||||
this.transitions.push({ from, to: next });
|
||||
}
|
||||
|
||||
block(code, source = null, line = 0, extra = {}) {
|
||||
const row = diagnostic(code, source, line, extra);
|
||||
this.diagnostics.push(row);
|
||||
if (this.state !== "BLOCKED") {
|
||||
const from = this.state;
|
||||
this.state = "BLOCKED";
|
||||
this.transitions.push({ from, to: "BLOCKED", diagnostic: clone(row) });
|
||||
}
|
||||
this.stateHash = this.hash();
|
||||
return this.report();
|
||||
}
|
||||
|
||||
mount() {
|
||||
if (this.state !== "NEW") return this.report();
|
||||
try {
|
||||
this.workspace = asWorkspace(this.workspace ?? this.files);
|
||||
if (!text(this.entrypoint)) throw Object.assign(new Error(STARTUP_ERRORS.invalid_input), { code: STARTUP_ERRORS.invalid_input });
|
||||
this.entrypoint = normalizeWorkspacePath(this.entrypoint);
|
||||
if (!this.entrypoint) throw Object.assign(new Error(STARTUP_ERRORS.invalid_input), { code: STARTUP_ERRORS.invalid_input });
|
||||
this.workspace.stat(`/workspace/${this.entrypoint}`);
|
||||
this.transition("MOUNTED");
|
||||
} catch (cause) {
|
||||
return this.block(cause.code ?? cause.message ?? STARTUP_ERRORS.workspace, this.entrypoint, cause.line ?? 0, { cause: String(cause?.message ?? cause) });
|
||||
}
|
||||
return this.report();
|
||||
}
|
||||
|
||||
parse() {
|
||||
if (this.state !== "MOUNTED") return this.report();
|
||||
try {
|
||||
this.ini = parseIni({ workspace: this.workspace, entrypoint: this.entrypoint, variables: this.variables, maxDepth: this.maxDepth });
|
||||
this.transition("PARSED");
|
||||
} catch (cause) {
|
||||
return this.block(cause.code ?? cause.message ?? STARTUP_ERRORS.ini, cause.source ?? this.entrypoint, cause.line ?? 0, {
|
||||
cause: String(cause?.message ?? cause),
|
||||
...(cause.variable ? { variable: cause.variable } : {}),
|
||||
});
|
||||
}
|
||||
return this.report();
|
||||
}
|
||||
|
||||
resolve() {
|
||||
if (this.state !== "PARSED") return this.report();
|
||||
try {
|
||||
this.resources = resolveResources({ workspace: this.workspace, entrypoint: this.entrypoint, ini: this.ini });
|
||||
} catch (cause) {
|
||||
return this.block(cause.code ?? cause.message ?? STARTUP_ERRORS.resources, this.entrypoint, cause.line ?? 0, { cause: String(cause?.message ?? cause) });
|
||||
}
|
||||
const blocked = this.resources.resources.filter((row) => row.status !== "resolved");
|
||||
if (blocked.length) {
|
||||
this.diagnostics.push(...blocked.map((row) => ({ ...row, status: "blocked" })));
|
||||
return this.block(STARTUP_ERRORS.resources, blocked[0].source ?? this.entrypoint, blocked[0].line ?? 0, { resources: clone(blocked) });
|
||||
}
|
||||
this.transition("RESOLVED");
|
||||
return this.report();
|
||||
}
|
||||
|
||||
createCore() {
|
||||
if (this.state !== "RESOLVED") return this.report();
|
||||
try {
|
||||
const result = this.coreCreated ? 0 : coreCall(this.core, "sim_create", 0, 0);
|
||||
this.coreResult = Number.isFinite(result) ? result : 0;
|
||||
if (result !== 0) return this.block(STARTUP_ERRORS.core, this.entrypoint, 0, { result });
|
||||
const loaded = coreCall(this.core, "sim_load_workspace", "/workspace");
|
||||
if (loaded !== 0) return this.block(STARTUP_ERRORS.core, this.entrypoint, 0, { result: loaded, operation: "sim_load_workspace" });
|
||||
this.transition("CORE_CREATED");
|
||||
} catch (cause) {
|
||||
return this.block(STARTUP_ERRORS.core, this.entrypoint, 0, { cause: String(cause?.message ?? cause) });
|
||||
}
|
||||
return this.report();
|
||||
}
|
||||
|
||||
prepareHal() {
|
||||
if (this.state !== "CORE_CREATED") return this.report();
|
||||
const records = [];
|
||||
for (const resource of this.resources.resources.filter((row) => row.status === "resolved" && row.type === "hal")) {
|
||||
let lines;
|
||||
try {
|
||||
const rawLines = this.workspace.read(`/workspace/${resource.path}`).split(/\r?\n/);
|
||||
lines = [];
|
||||
for (const rawLine of rawLines) {
|
||||
if (lines.length > 0 && /\\\s*$/.test(lines.at(-1))) lines[lines.length - 1] = `${lines.at(-1).replace(/\\\s*$/, "")} ${rawLine.trim()}`;
|
||||
else lines.push(rawLine);
|
||||
}
|
||||
} catch (cause) {
|
||||
return this.block(STARTUP_ERRORS.hal, resource.path, resource.line ?? 0, { cause: String(cause?.message ?? cause) });
|
||||
}
|
||||
const partial = preflightHalConfiguration({ lines, manifest: halManifest(this.manifest), variables: this.ini?.sections ?? {} });
|
||||
records.push(...partial.records.map((record) => ({ ...record, source: resource.path, resource_id: resource.resource_id })));
|
||||
}
|
||||
const blockedRecords = records.filter((record) => record.status === "blocked");
|
||||
this.hal = {
|
||||
schema: "hal-configuration-preflight.v1",
|
||||
status: blockedRecords.length ? "blocked" : "ready",
|
||||
records,
|
||||
record_count: records.length,
|
||||
blocked_count: blockedRecords.length,
|
||||
};
|
||||
if (this.hal.status !== "ready") {
|
||||
const first = this.hal.records.find((row) => row.status === "blocked");
|
||||
return this.block(STARTUP_ERRORS.hal, first?.source ?? this.entrypoint, first?.line ?? 0, { hal: clone(this.hal) });
|
||||
}
|
||||
this.transition("HAL_READY");
|
||||
return this.report();
|
||||
}
|
||||
|
||||
prepareTask() {
|
||||
if (this.state !== "HAL_READY") return this.report();
|
||||
try {
|
||||
if (this.task && typeof this.task.transition === "function") {
|
||||
const current = this.task.state?.state ?? this.task.state;
|
||||
if (current === "NEW" && typeof this.task.transition === "function") this.task.transition("READY");
|
||||
}
|
||||
this.transition("TASK_READY");
|
||||
} catch (cause) {
|
||||
return this.block(STARTUP_ERRORS.task, this.entrypoint, 0, { cause: String(cause?.message ?? cause) });
|
||||
}
|
||||
return this.report();
|
||||
}
|
||||
|
||||
run() {
|
||||
for (const step of ["mount", "parse", "resolve", "createCore", "prepareHal", "prepareTask"]) {
|
||||
if (this.state === "BLOCKED" || this.state === "TASK_READY") break;
|
||||
this[step]();
|
||||
}
|
||||
this.stateHash = this.hash();
|
||||
return this.report();
|
||||
}
|
||||
|
||||
hash() {
|
||||
const unsigned = this.unsignedReport();
|
||||
return fnv1a64(unsigned);
|
||||
}
|
||||
|
||||
unsignedReport() {
|
||||
return {
|
||||
schema: CONFIGURATION_STARTUP_SCHEMA,
|
||||
entrypoint: this.entrypoint,
|
||||
state: this.state,
|
||||
transitions: this.transitions,
|
||||
diagnostics: this.diagnostics,
|
||||
ini: this.ini ? { state_hash: this.ini.state_hash, graph: this.ini.graph, includes: this.ini.includes } : null,
|
||||
resources: this.resources ? { state_hash: this.resources.state_hash, resolved_count: this.resources.resolved_count, blocked_count: this.resources.blocked_count, resources: this.resources.resources } : null,
|
||||
hal: this.hal ? { schema: this.hal.schema, status: this.hal.status, record_count: this.hal.record_count, blocked_count: this.hal.blocked_count, records: this.hal.records } : null,
|
||||
core: this.coreResult === null ? null : { result: this.coreResult },
|
||||
};
|
||||
}
|
||||
|
||||
report() {
|
||||
const unsigned = this.unsignedReport();
|
||||
return { ...clone(unsigned), status: this.state === "TASK_READY" ? "ready" : "blocked", state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
}
|
||||
|
||||
export function startConfiguration(options = {}) {
|
||||
return new ConfigurationStartupStateMachine(options).run();
|
||||
}
|
||||
|
||||
export function createConfigurationStartup(options = {}) {
|
||||
return new ConfigurationStartupStateMachine(options);
|
||||
}
|
||||
|
||||
export const ConfigurationStartup = ConfigurationStartupStateMachine;
|
||||
export const STARTUP_SCHEMA = CONFIGURATION_STARTUP_SCHEMA;
|
||||
@@ -143,3 +143,13 @@ export function preflightConfiguration({ entrypoint, rows = [], availablePaths =
|
||||
}
|
||||
|
||||
export { CONFIGURATION_SCHEMA };
|
||||
export {
|
||||
CONFIGURATION_STARTUP_SCHEMA,
|
||||
STARTUP_SCHEMA,
|
||||
STARTUP_STATES,
|
||||
STARTUP_ERRORS,
|
||||
ConfigurationStartup,
|
||||
ConfigurationStartupStateMachine,
|
||||
startConfiguration,
|
||||
createConfigurationStartup,
|
||||
} from "./configuration-startup.mjs";
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
export const HAL_CONFIGURATION_SCHEMA = "hal-configuration-preflight.v1";
|
||||
|
||||
const BUILTIN_THREADS = new Set(["servo-thread", "base-thread"]);
|
||||
export const VIRTUAL_HAL_USER_COMPONENTS = Object.freeze(["hal_manualtoolchange", "lineardelta", "rotarydelta"]);
|
||||
|
||||
function text(value) {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function parseHalLine(raw, line = 0) {
|
||||
function expandHalVariables(value, variables = {}) {
|
||||
return value.replace(/\[([^\]]+)\]([A-Za-z_][A-Za-z0-9_.-]*)/g, (match, section, key) => {
|
||||
const sectionValues = variables[section] ?? variables[section.toUpperCase()] ?? {};
|
||||
return Object.prototype.hasOwnProperty.call(sectionValues, key) ? String(sectionValues[key]) : match;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseHalLine(raw, line = 0, variables = {}) {
|
||||
if (typeof raw !== "string") return { status: "blocked", code: "hal_line_invalid", line };
|
||||
const clean = raw.split("#", 1)[0].trim();
|
||||
const clean = expandHalVariables(raw.split("#", 1)[0].trim(), variables);
|
||||
if (!clean) return null;
|
||||
const tokens = clean.split(/\s+/);
|
||||
const command = tokens[0];
|
||||
if (!["loadrt", "loadusr", "addf", "net", "setp", "source", "newsig", "linksp", "linkps", "linkpp"].includes(command)) return null;
|
||||
if (!["loadrt", "loadusr", "addf", "net", "setp", "source", "newsig", "linksp", "linkps", "linkpp", "show", "delsig", "sets", "unlinkp"].includes(command)) {
|
||||
return { schema: HAL_CONFIGURATION_SCHEMA, line, command, status: "blocked", code: "hal_command_unsupported" };
|
||||
}
|
||||
if (command === "loadrt") {
|
||||
const module = tokens[1] ?? "";
|
||||
const namesToken = tokens.find((token) => token.startsWith("names="));
|
||||
return { schema: HAL_CONFIGURATION_SCHEMA, line, command, module, instances: namesToken ? namesToken.slice(6).split(",") : [], status: module.startsWith("[") ? "blocked" : "declared", code: module.startsWith("[") ? "hal_component_variable" : null };
|
||||
}
|
||||
if (command === "loadusr") return { schema: HAL_CONFIGURATION_SCHEMA, line, command, status: "blocked", code: "hal_host_dynamic_loader", program: tokens[1] ?? "" };
|
||||
if (command === "loadusr") {
|
||||
const program = tokens.find((token, index) => index > 0 && !token.startsWith("-")) ?? "";
|
||||
const virtual = VIRTUAL_HAL_USER_COMPONENTS.includes(program);
|
||||
return { schema: HAL_CONFIGURATION_SCHEMA, line, command, status: virtual ? "checked" : "blocked", code: virtual ? null : "hal_host_dynamic_loader", program, ...(virtual ? { capability: "SIM-ADAPTER" } : {}) };
|
||||
}
|
||||
if (command === "addf") {
|
||||
const thread = tokens[2] ?? "";
|
||||
return { schema: HAL_CONFIGURATION_SCHEMA, line, command, function: tokens[1] ?? null, thread, status: text(thread) && BUILTIN_THREADS.has(thread) ? "checked" : "blocked", code: text(thread) && BUILTIN_THREADS.has(thread) ? null : "hal_thread_undeclared" };
|
||||
@@ -26,11 +40,11 @@ export function parseHalLine(raw, line = 0) {
|
||||
return { schema: HAL_CONFIGURATION_SCHEMA, line, command, tokens: tokens.slice(1), status: "checked", code: null };
|
||||
}
|
||||
|
||||
export function preflightHalConfiguration({ lines = [], manifest = [] } = {}) {
|
||||
export function preflightHalConfiguration({ lines = [], manifest = [], variables = {} } = {}) {
|
||||
const declared = new Set((Array.isArray(manifest) ? manifest : []).map((row) => row?.component).filter(Boolean));
|
||||
const records = [];
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const parsed = parseHalLine(lines[index], index + 1);
|
||||
const parsed = parseHalLine(lines[index], index + 1, variables);
|
||||
if (!parsed) continue;
|
||||
if (parsed.command === "loadrt" && parsed.status === "declared" && !declared.has(parsed.module)) {
|
||||
parsed.status = "blocked";
|
||||
|
||||
120
web/workspace/ini-parser.mjs
Normal file
120
web/workspace/ini-parser.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
import { fnv1a64 } from "../worker/replay.mjs";
|
||||
import { normalizeWorkspacePath } from "./runtime.mjs";
|
||||
|
||||
export const INI_SCHEMA = "ini-resource-graph.v1";
|
||||
export const INI_ERRORS = Object.freeze({
|
||||
missing: "ini_source_missing",
|
||||
cycle: "ini_include_cycle",
|
||||
depth: "ini_include_depth",
|
||||
section: "ini_section_invalid",
|
||||
key: "ini_key_invalid",
|
||||
assignment: "ini_assignment_invalid",
|
||||
variable: "ini_variable_missing",
|
||||
path: "ini_path_invalid",
|
||||
});
|
||||
|
||||
function clone(value) { return structuredClone(value); }
|
||||
function error(code, path, line, message = code, extra = {}) {
|
||||
return Object.assign(new Error(message), { code, source: path, line, column: 1, ...extra });
|
||||
}
|
||||
function stripComment(line) {
|
||||
let quote = null;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
if ((char === "\"" || char === "'") && (!quote || quote === char)) quote = quote ? null : char;
|
||||
if (!quote && (char === ";" || char === "#")) return line.slice(0, index);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
function resolveInclude(basePath, includePath) {
|
||||
if (includePath.startsWith("/") || includePath.includes("\\")) throw error(INI_ERRORS.path, basePath, 0, "ini_include_path_invalid");
|
||||
const baseParts = basePath.split("/");
|
||||
baseParts.pop();
|
||||
return normalizeWorkspacePath(`/workspace/${[...baseParts, includePath].join("/")}`);
|
||||
}
|
||||
function expand(value, variables, path, line) {
|
||||
return value.replace(/\$\{([^}]+)\}|\$\(([^)]+)\)|\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, brace, paren, plain) => {
|
||||
const name = brace ?? paren ?? plain;
|
||||
if (!(name in variables)) throw error(INI_ERRORS.variable, path, line, "ini_variable_missing", { variable: name });
|
||||
return String(variables[name]);
|
||||
});
|
||||
}
|
||||
|
||||
export function parseIni({ workspace, entrypoint, variables = {}, maxDepth = 32 } = {}) {
|
||||
if (!workspace || typeof workspace.read !== "function") throw new Error("ini_workspace_required");
|
||||
const root = normalizeWorkspacePath(entrypoint);
|
||||
const sections = {};
|
||||
const entries = [];
|
||||
const includes = [];
|
||||
const diagnostics = [];
|
||||
const graph = [];
|
||||
const active = [];
|
||||
const vars = { ...variables };
|
||||
|
||||
function parseFile(path, depth) {
|
||||
if (depth > maxDepth) throw error(INI_ERRORS.depth, path, 0, "ini_include_depth", { maxDepth });
|
||||
if (active.includes(path)) throw error(INI_ERRORS.cycle, path, 0, "ini_include_cycle", { cycle: [...active, path] });
|
||||
active.push(path);
|
||||
let source;
|
||||
try { source = workspace.read(`/workspace/${path}`); } catch (cause) {
|
||||
active.pop();
|
||||
throw error(INI_ERRORS.missing, path, 0, "ini_source_missing", { cause: String(cause?.message ?? cause) });
|
||||
}
|
||||
graph.push(path);
|
||||
let section = "";
|
||||
const lines = source.split(/\r?\n/);
|
||||
lines.forEach((rawLine, offset) => {
|
||||
const lineNumber = offset + 1;
|
||||
const hashInclude = rawLine.trim().match(/^#INCLUDE\s+(.+)$/i);
|
||||
if (hashInclude) {
|
||||
const rawInclude = hashInclude[1].trim().replace(/^['"]|['"]$/g, "");
|
||||
let child;
|
||||
try { child = resolveInclude(path, expand(rawInclude, vars, path, lineNumber)); } catch (cause) { throw cause.code ? cause : error(INI_ERRORS.path, path, lineNumber, "ini_include_path_invalid"); }
|
||||
includes.push({ source: path, line: lineNumber, path: child });
|
||||
parseFile(child, depth + 1);
|
||||
return;
|
||||
}
|
||||
const clean = stripComment(rawLine).trim();
|
||||
if (!clean) return;
|
||||
const includeMatch = clean.match(/^(?:include|INCLUDE)\s*(?:=\s*)?(.+)$/);
|
||||
if (includeMatch) {
|
||||
const rawInclude = includeMatch[1].trim().replace(/^['"]|['"]$/g, "");
|
||||
let child;
|
||||
try { child = resolveInclude(path, expand(rawInclude, vars, path, lineNumber)); } catch (cause) { throw cause.code ? cause : error(INI_ERRORS.path, path, lineNumber, "ini_include_path_invalid"); }
|
||||
includes.push({ source: path, line: lineNumber, path: child });
|
||||
parseFile(child, depth + 1);
|
||||
return;
|
||||
}
|
||||
const sectionMatch = clean.match(/^\[([^\]]+)\]$/);
|
||||
if (sectionMatch) {
|
||||
section = sectionMatch[1].trim();
|
||||
if (!section || /[\0\r\n]/.test(section)) throw error(INI_ERRORS.section, path, lineNumber, "ini_section_invalid");
|
||||
if (!sections[section]) sections[section] = {};
|
||||
return;
|
||||
}
|
||||
const assignment = clean.match(/^([^=:#]+?)\s*(?:=|:)\s*(.*)$/);
|
||||
if (!assignment || !section) throw error(!section ? INI_ERRORS.section : INI_ERRORS.assignment, path, lineNumber, !section ? "ini_section_invalid" : "ini_assignment_invalid");
|
||||
const key = assignment[1].trim();
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(key)) throw error(INI_ERRORS.key, path, lineNumber, "ini_key_invalid");
|
||||
const value = expand(assignment[2].trim(), vars, path, lineNumber);
|
||||
sections[section][key] = value;
|
||||
if (section.toUpperCase() === "VAR" || section.toUpperCase() === "VARIABLES") vars[key] = value;
|
||||
entries.push({ source: path, line: lineNumber, section, key, value });
|
||||
});
|
||||
active.pop();
|
||||
}
|
||||
|
||||
parseFile(root, 0);
|
||||
const normalizedSections = Object.fromEntries(Object.keys(sections).sort().map((name) => [name, Object.fromEntries(Object.keys(sections[name]).sort().map((key) => [key, sections[name][key]]))]));
|
||||
const result = {
|
||||
schema: INI_SCHEMA,
|
||||
entrypoint: root,
|
||||
sections: normalizedSections,
|
||||
entries,
|
||||
includes,
|
||||
graph: [...new Set(graph)],
|
||||
variables: Object.fromEntries(Object.keys(vars).sort().map((key) => [key, vars[key]])),
|
||||
diagnostics,
|
||||
};
|
||||
return { ...result, state_hash: fnv1a64(result) };
|
||||
}
|
||||
@@ -25,10 +25,13 @@ function dataHash(data) {
|
||||
}
|
||||
|
||||
export class WorkspaceJournal {
|
||||
constructor(storage = new MemoryStorage(), { projectId = "default", schemaVersion = 1 } = {}) {
|
||||
constructor(storage = new MemoryStorage(), { projectId = "default", schemaVersion = 1, maxRecords = 4096, maxBytes = 64 * 1024 * 1024 } = {}) {
|
||||
if (!Number.isSafeInteger(maxRecords) || maxRecords < 2 || !Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error("workspace_journal_quota_invalid");
|
||||
this.storage = storage;
|
||||
this.projectId = projectId;
|
||||
this.schemaVersion = schemaVersion;
|
||||
this.maxRecords = maxRecords;
|
||||
this.maxBytes = maxBytes;
|
||||
}
|
||||
|
||||
records() {
|
||||
@@ -42,6 +45,8 @@ export class WorkspaceJournal {
|
||||
prepare(operationId, changes) {
|
||||
if (typeof operationId !== "string" || operationId.length === 0) throw new Error("operation_id_invalid");
|
||||
if (!Array.isArray(changes) || changes.length === 0) throw new Error("workspace_changes_empty");
|
||||
if (changes.length > 256) throw new Error("workspace_change_capacity");
|
||||
if (this.records().length + 2 > this.maxRecords) throw new Error("workspace_journal_quota");
|
||||
const existing = this.records().find((record) => record.operation_id === operationId && record.kind === "commit");
|
||||
if (existing) throw new Error("operation_already_committed");
|
||||
const normalized = changes.map((change) => {
|
||||
@@ -49,6 +54,8 @@ export class WorkspaceJournal {
|
||||
const data = change.data;
|
||||
return { path, data, data_hash: dataHash(data) };
|
||||
}).sort((left, right) => left.path.localeCompare(right.path));
|
||||
const normalizedBytes = normalized.reduce((total, change) => total + new TextEncoder().encode(change.data).byteLength, 0);
|
||||
if (normalizedBytes > this.maxBytes) throw new Error("workspace_journal_quota");
|
||||
const commitSeq = this.nextSequence();
|
||||
const record = {
|
||||
schema: JOURNAL_SCHEMA,
|
||||
|
||||
@@ -190,21 +190,28 @@ export function applyRollbackPackage(rollback, { currentPackageHash, failureInje
|
||||
return clone(target);
|
||||
}
|
||||
|
||||
function normalizeResources(resources) {
|
||||
function normalizeResources(resources, { maxResources = 256, maxBytes = 64 * 1024 * 1024 } = {}) {
|
||||
if (!Array.isArray(resources) || resources.length === 0) throw new Error("offline_resources_empty");
|
||||
if (resources.length > maxResources) throw new Error("offline_resource_capacity");
|
||||
const seen = new Set();
|
||||
return resources.map((resource) => {
|
||||
let totalBytes = 0;
|
||||
const normalized = resources.map((resource) => {
|
||||
requireString(resource?.path, "offline_resource_path");
|
||||
if (resource.path.startsWith("/") || resource.path.includes("\\") || resource.path.split("/").includes("..")) throw new Error("offline_resource_path");
|
||||
if (seen.has(resource.path)) throw new Error("offline_resource_duplicate");
|
||||
seen.add(resource.path);
|
||||
if (typeof resource.data !== "string") throw new Error("offline_resource_data_invalid");
|
||||
return { path: resource.path, bytes: new TextEncoder().encode(resource.data).byteLength, data_hash: deterministicHash(resource.data) };
|
||||
const bytes = new TextEncoder().encode(resource.data).byteLength;
|
||||
totalBytes += bytes;
|
||||
if (totalBytes > maxBytes) throw new Error("offline_resource_quota");
|
||||
return { path: resource.path, bytes, data_hash: deterministicHash(resource.data) };
|
||||
}).sort((left, right) => left.path.localeCompare(right.path));
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createOfflinePackage({ workspacePackage, resources } = {}) {
|
||||
export function createOfflinePackage({ workspacePackage, resources, maxResources = 256, maxBytes = 64 * 1024 * 1024 } = {}) {
|
||||
const workspace = validatePackageShape(workspacePackage);
|
||||
const normalized = normalizeResources(resources);
|
||||
const normalized = normalizeResources(resources, { maxResources, maxBytes });
|
||||
const bundle = {
|
||||
schema: OFFLINE_SCHEMA,
|
||||
format_version: 1,
|
||||
@@ -221,7 +228,15 @@ export function verifyOfflinePackage(bundle, { contextId, workspacePackage = nul
|
||||
requireString(bundle.context_id, "offline_context");
|
||||
if (contextId !== undefined && bundle.context_id !== contextId) throw new Error("offline_context_mismatch");
|
||||
if (workspacePackage !== null && verifyWorkspacePackage(workspacePackage, { contextId: bundle.context_id }).package_hash !== bundle.workspace_package_hash) throw new Error("offline_workspace_hash_mismatch");
|
||||
if (!Array.isArray(bundle.resources) || !Array.isArray(bundle.required_paths) || canonicalJson(bundle.required_paths) !== canonicalJson(bundle.resources.map((resource) => resource.path))) throw new Error("offline_manifest_invalid");
|
||||
if (!Array.isArray(bundle.resources) || !Array.isArray(bundle.required_paths) || bundle.resources.length > 256 || canonicalJson(bundle.required_paths) !== canonicalJson(bundle.resources.map((resource) => resource.path))) throw new Error("offline_manifest_invalid");
|
||||
const resourcePaths = new Set();
|
||||
let resourceBytes = 0;
|
||||
for (const resource of bundle.resources) {
|
||||
requireString(resource?.path, "offline_resource_path");
|
||||
if (resource.path.startsWith("/") || resource.path.includes("\\") || resource.path.split("/").includes("..") || resourcePaths.has(resource.path) || !Number.isSafeInteger(resource.bytes) || resource.bytes < 0 || typeof resource.data_hash !== "string") throw new Error("offline_manifest_invalid");
|
||||
resourcePaths.add(resource.path); resourceBytes += resource.bytes;
|
||||
if (resourceBytes > 64 * 1024 * 1024) throw new Error("offline_manifest_quota");
|
||||
}
|
||||
if (typeof bundle.bundle_hash !== "string" || bundle.bundle_hash !== deterministicHash({ schema: bundle.schema, format_version: bundle.format_version, context_id: bundle.context_id, workspace_package_hash: bundle.workspace_package_hash, resources: bundle.resources, required_paths: bundle.required_paths })) throw new Error("offline_bundle_hash_mismatch");
|
||||
if (!Array.isArray(available)) throw new Error("offline_available_invalid");
|
||||
const availableMap = new Map(available.map((entry) => {
|
||||
|
||||
76
web/workspace/opfs-runtime.mjs
Normal file
76
web/workspace/opfs-runtime.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
import { fnv1a64 } from "../worker/replay.mjs";
|
||||
|
||||
export const OPFS_RUNTIME_SCHEMA = "opfs-runtime.v1";
|
||||
const FILE_NAME = "commit-record.json";
|
||||
const TEXT_ENCODER = new TextEncoder();
|
||||
const PROBE_TIMEOUT_MS = 1500;
|
||||
|
||||
function capability(storage) {
|
||||
return storage && typeof storage.getDirectory === "function";
|
||||
}
|
||||
|
||||
export function opfsCapability(scope = globalThis) {
|
||||
const storage = scope?.navigator?.storage;
|
||||
if (!capability(storage)) return { status: "blocked", code: "opfs_unavailable", reason: "storage_get_directory_missing" };
|
||||
return { status: "verified", code: "opfs_available", mode: "dedicated_worker_sync_access_handle" };
|
||||
}
|
||||
|
||||
function validProjectId(projectId) {
|
||||
return typeof projectId === "string" && /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(projectId);
|
||||
}
|
||||
|
||||
function committedRecord(projectId) {
|
||||
const content = "s6-05-opfs-round-trip";
|
||||
return { schema: OPFS_RUNTIME_SCHEMA, project_id: projectId, phase: "commit", commit_seq: 1, content, content_hash: fnv1a64(content) };
|
||||
}
|
||||
|
||||
function validateCommittedRecord(record, projectId) {
|
||||
return record?.schema === OPFS_RUNTIME_SCHEMA && record.project_id === projectId && record.phase === "commit" && record.commit_seq === 1 && record.content_hash === fnv1a64(record.content);
|
||||
}
|
||||
|
||||
async function rootHandle(storage) {
|
||||
const timeout = new Promise((resolve) => setTimeout(() => resolve({ timeout: true }), PROBE_TIMEOUT_MS));
|
||||
const rootResult = await Promise.race([storage.getDirectory().then((root) => ({ root })), timeout]);
|
||||
return rootResult.timeout ? { blocked: { code: "opfs_probe_timeout", stage: "root_start" } } : { root: rootResult.root };
|
||||
}
|
||||
|
||||
export async function runOpfsProbe({ storage, projectId = "s6-05-opfs", mode = "round_trip", cleanup = true } = {}) {
|
||||
const probe = opfsCapability({ navigator: { storage } });
|
||||
if (probe.status !== "verified") return { schema: OPFS_RUNTIME_SCHEMA, ...probe, project_id: projectId };
|
||||
if (!validProjectId(projectId)) return { schema: OPFS_RUNTIME_SCHEMA, status: "blocked", code: "opfs_project_id_invalid" };
|
||||
if (!["round_trip", "write", "read"].includes(mode)) return { schema: OPFS_RUNTIME_SCHEMA, status: "blocked", code: "opfs_mode_invalid", project_id: projectId };
|
||||
let access = null;
|
||||
try {
|
||||
const rootResult = await rootHandle(storage);
|
||||
if (rootResult.blocked) return { schema: OPFS_RUNTIME_SCHEMA, status: "blocked", ...rootResult.blocked, project_id: projectId };
|
||||
const root = rootResult.root;
|
||||
const directory = await root.getDirectoryHandle("linuxcnc-web-wasm", { create: true });
|
||||
const project = await directory.getDirectoryHandle(projectId, { create: true });
|
||||
const file = await project.getFileHandle(FILE_NAME, { create: mode !== "read" });
|
||||
if (mode === "read") {
|
||||
const persisted = JSON.parse(await (await file.getFile()).text());
|
||||
const valid = validateCommittedRecord(persisted, projectId);
|
||||
if (cleanup) { await project.removeEntry(FILE_NAME); await directory.removeEntry(projectId); }
|
||||
return valid ? { schema: OPFS_RUNTIME_SCHEMA, status: "verified", mode, project_id: projectId, commit_seq: persisted.commit_seq, content_hash: persisted.content_hash, cleanup } : { schema: OPFS_RUNTIME_SCHEMA, status: "blocked", code: "opfs_persisted_record_invalid", project_id: projectId };
|
||||
}
|
||||
if (typeof file.createSyncAccessHandle !== "function") return { schema: OPFS_RUNTIME_SCHEMA, status: "blocked", code: "opfs_sync_access_handle_missing", project_id: projectId };
|
||||
access = await file.createSyncAccessHandle();
|
||||
const record = committedRecord(projectId);
|
||||
const encoded = TEXT_ENCODER.encode(JSON.stringify(record));
|
||||
access.truncate(0);
|
||||
const written = access.write(encoded, { at: 0 });
|
||||
access.flush();
|
||||
const size = access.getSize();
|
||||
access.close();
|
||||
access = null;
|
||||
if (mode === "write") return { schema: OPFS_RUNTIME_SCHEMA, status: "verified", mode, project_id: projectId, bytes: encoded.byteLength, commit_seq: record.commit_seq, content_hash: record.content_hash, cleanup: false };
|
||||
const persisted = JSON.parse(await (await file.getFile()).text());
|
||||
const valid = written === encoded.byteLength && size === encoded.byteLength && JSON.stringify(persisted) === JSON.stringify(record);
|
||||
if (cleanup) { await project.removeEntry(FILE_NAME); await directory.removeEntry(projectId); }
|
||||
if (!valid) return { schema: OPFS_RUNTIME_SCHEMA, status: "blocked", code: "opfs_round_trip_mismatch", project_id: projectId };
|
||||
return { schema: OPFS_RUNTIME_SCHEMA, status: "verified", mode: "dedicated_worker_sync_access_handle", project_id: projectId, bytes: encoded.byteLength, commit_seq: record.commit_seq, content_hash: record.content_hash, cleanup: true };
|
||||
} catch (error) {
|
||||
try { access?.close(); } catch { /* best effort close after a browser fault */ }
|
||||
return { schema: OPFS_RUNTIME_SCHEMA, status: "blocked", code: "opfs_probe_failed", project_id: projectId, reason: error?.name ?? "unknown" };
|
||||
}
|
||||
}
|
||||
84
web/workspace/resource-resolver.mjs
Normal file
84
web/workspace/resource-resolver.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
import { fnv1a64 } from "../worker/replay.mjs";
|
||||
import { parseIni } from "./ini-parser.mjs";
|
||||
import { normalizeWorkspacePath } from "./runtime.mjs";
|
||||
|
||||
export const RESOURCE_GRAPH_SCHEMA = "resource-graph.v1";
|
||||
export const RESOURCE_TYPES = Object.freeze(["hal", "tool_table", "parameter_file", "program", "python", "vcp", "classicladder", "image"]);
|
||||
const KEY_MAP = Object.freeze({
|
||||
HALFILE: "hal", HAL: "hal", TOOLTABLE: "tool_table", TOOL_TABLE: "tool_table", VARFILE: "parameter_file", PARAMETER_FILE: "parameter_file",
|
||||
PROGRAM: "program", PROGRAMS: "program", PYTHON: "python", PYTHONFILE: "python", VCP: "vcp", VCPFILE: "vcp", CLASSICLADDER: "classicladder", CLPFILE: "classicladder",
|
||||
INTRO_GRAPHIC: "image",
|
||||
});
|
||||
const SHARED_SIMULATION_ASSETS = Object.freeze({ "linuxcnc.gif": "linuxcnc/linuxcnc.gif" });
|
||||
|
||||
function clone(value) { return structuredClone(value); }
|
||||
function blocked(code, source, line, extra = {}) { return { status: "blocked", code, source, line, ...extra }; }
|
||||
function pathOf(base, raw) {
|
||||
if (typeof raw !== "string" || !raw.trim()) throw new Error("resource_path_empty");
|
||||
const value = raw.trim().replace(/^['"]|['"]$/g, "");
|
||||
if (value.startsWith("/") || value.startsWith("~") || value.startsWith("$") || value.includes("\\")) throw new Error("resource_host_path");
|
||||
const baseParts = base.split("/"); baseParts.pop();
|
||||
return normalizeWorkspacePath(`/workspace/${[...baseParts, value].join("/")}`);
|
||||
}
|
||||
|
||||
function pathCandidates(base, raw, key = null, section = null) {
|
||||
if (typeof raw !== "string") throw new Error("resource_path_empty");
|
||||
const value = raw.trim().replace(/^['"]|['"]$/g, "");
|
||||
const normalizedKey = typeof key === "string" ? key.toUpperCase() : null;
|
||||
const normalizedSection = typeof section === "string" ? section.toUpperCase() : null;
|
||||
// LinuxCNC's generator emits this exact DISPLAY entry. Keep the shared asset
|
||||
// mapping scoped to that source-proven contract; other resource types resolve
|
||||
// relative to their configuration and never inherit a basename fallback.
|
||||
if (normalizedKey === "INTRO_GRAPHIC" && (normalizedSection === "DISPLAY" || normalizedSection === null) && value === "linuxcnc.gif") return [SHARED_SIMULATION_ASSETS["linuxcnc.gif"]];
|
||||
if (value.startsWith("LIB:")) {
|
||||
const libraryName = value.slice("LIB:".length);
|
||||
if (!libraryName || libraryName.startsWith("/") || libraryName.includes("..") || libraryName.includes("\\")) throw new Error("resource_path_invalid");
|
||||
return ["linuxcnc/lib/hallib", "linuxcnc/lib"].map((root) => normalizeWorkspacePath(`/workspace/${root}/${libraryName}`));
|
||||
}
|
||||
if (["HAL", "HALFILE"].includes(normalizedKey) && !value.includes("/") && !value.startsWith("linuxcnc/")) {
|
||||
return [pathOf(base, raw), `linuxcnc/lib/hallib/${value}`, `linuxcnc/lib/${value}`].map((candidate) => normalizeWorkspacePath(`/workspace/${candidate}`));
|
||||
}
|
||||
return [pathOf(base, raw)];
|
||||
}
|
||||
function inferType(path) {
|
||||
const ext = path.toLowerCase().split(".").pop();
|
||||
return ({ hal: "hal", tbl: "tool_table", var: "parameter_file", ngc: "program", gcode: "program", py: "python", xml: "vcp", clp: "classicladder" })[ext] ?? null;
|
||||
}
|
||||
|
||||
export function resolveResources({ workspace, entrypoint, ini = null, explicit = [] } = {}) {
|
||||
if (!workspace || typeof workspace.read !== "function") throw new Error("resource_workspace_required");
|
||||
const parsed = ini ?? parseIni({ workspace, entrypoint });
|
||||
const resources = [];
|
||||
const seen = new Map();
|
||||
const add = (type, raw, source, line, key, section = null) => {
|
||||
let candidates;
|
||||
try { candidates = pathCandidates(source, raw, key, section); } catch (cause) { resources.push(blocked(cause.message === "resource_host_path" ? "resource_host_path" : "resource_path_invalid", source, line, { type, key, raw })); return; }
|
||||
const existing = candidates.filter((candidate) => {
|
||||
try { workspace.read(`/workspace/${candidate}`); return true; } catch { return false; }
|
||||
});
|
||||
if (existing.length > 1) {
|
||||
resources.push(blocked("resource_ambiguous", source, line, { type, key, raw, attempted_paths: candidates }));
|
||||
return;
|
||||
}
|
||||
const path = existing[0] ?? candidates[0];
|
||||
const actualType = type ?? inferType(path);
|
||||
if (!actualType) { resources.push(blocked("resource_type_unknown", source, line, { raw, path })); return; }
|
||||
const id = `${actualType}:${path}`;
|
||||
if (seen.has(id)) { resources.push(blocked("resource_duplicate", source, line, { type: actualType, path, first: seen.get(id) })); return; }
|
||||
seen.set(id, { source, line });
|
||||
try {
|
||||
const data = workspace.read(`/workspace/${path}`);
|
||||
const record = { status: "resolved", resource_id: id, type: actualType, path, source, line, key: key ?? null, bytes: new TextEncoder().encode(data).byteLength, data_hash: fnv1a64(data), capability: actualType === "python" ? "PY-WASM" : actualType === "vcp" ? "WEB-EQUIV" : "SIM-CORE" };
|
||||
resources.push(record);
|
||||
} catch { resources.push(blocked("resource_missing", source, line, { type: actualType, path, key: key ?? null })); }
|
||||
};
|
||||
for (const entry of parsed.entries) {
|
||||
const type = KEY_MAP[entry.key.toUpperCase()];
|
||||
if (type) for (const raw of entry.value.split(/\s*,\s*|\s+/).filter(Boolean)) add(type, raw, entry.source, entry.line, entry.key, entry.section);
|
||||
}
|
||||
for (const item of explicit) add(item.type ?? inferType(item.path), item.path, item.source ?? parsed.entrypoint, item.line ?? 0, item.key ?? null);
|
||||
const sorted = resources.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
||||
const blockedRows = sorted.filter((row) => row.status !== "resolved");
|
||||
const result = { schema: RESOURCE_GRAPH_SCHEMA, entrypoint: parsed.entrypoint, ini_hash: parsed.state_hash, resources: sorted, resolved_count: sorted.length - blockedRows.length, blocked_count: blockedRows.length, status: blockedRows.length ? "blocked" : "ready" };
|
||||
return { ...result, state_hash: fnv1a64(result) };
|
||||
}
|
||||
122
web/workspace/runtime.mjs
Normal file
122
web/workspace/runtime.mjs
Normal file
@@ -0,0 +1,122 @@
|
||||
import { fnv1a64 } from "../worker/replay.mjs";
|
||||
|
||||
export const WORKSPACE_SCHEMA = "workspace-runtime.v1";
|
||||
export const WORKSPACE_DEFAULT_QUOTA = 64 * 1024 * 1024;
|
||||
|
||||
function clone(value) { return structuredClone(value); }
|
||||
|
||||
export function normalizeWorkspacePath(value) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.includes("\0") || value.includes("\\")) throw new Error("workspace_path_invalid");
|
||||
let path = value;
|
||||
if (path === "/workspace") return "";
|
||||
if (path.startsWith("/workspace/")) path = path.slice("/workspace/".length);
|
||||
else if (path.startsWith("/")) throw new Error("workspace_host_path");
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
if (parts.some((part) => part === "." || part === "..")) throw new Error("workspace_path_escape");
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function encoded(data) {
|
||||
if (typeof data !== "string") throw new Error("workspace_data_invalid");
|
||||
const bytes = new TextEncoder().encode(data);
|
||||
if (new TextDecoder("utf-8", { fatal: true }).decode(bytes) !== data) throw new Error("workspace_encoding_invalid");
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export class WorkspaceRuntime {
|
||||
constructor({ quotaBytes = WORKSPACE_DEFAULT_QUOTA, files = {} } = {}) {
|
||||
if (!Number.isSafeInteger(quotaBytes) || quotaBytes < 1) throw new Error("workspace_quota_invalid");
|
||||
this.quotaBytes = quotaBytes;
|
||||
this.files = new Map();
|
||||
this.transactions = new Map();
|
||||
for (const [path, data] of Object.entries(files)) this.files.set(normalizeWorkspacePath(path), data);
|
||||
this.assertQuota(this.files);
|
||||
}
|
||||
|
||||
assertQuota(files) {
|
||||
let total = 0;
|
||||
for (const [path, data] of files) {
|
||||
if (!path) throw new Error("workspace_path_invalid");
|
||||
const size = encoded(data).byteLength;
|
||||
if (size > this.quotaBytes) throw new Error("workspace_file_quota");
|
||||
total += size;
|
||||
}
|
||||
if (total > this.quotaBytes) throw new Error("workspace_quota_exceeded");
|
||||
}
|
||||
|
||||
read(path) {
|
||||
const normalized = normalizeWorkspacePath(path);
|
||||
if (!this.files.has(normalized)) throw new Error("workspace_file_missing");
|
||||
return this.files.get(normalized);
|
||||
}
|
||||
|
||||
stat(path) {
|
||||
const normalized = normalizeWorkspacePath(path);
|
||||
if (!this.files.has(normalized)) throw new Error("workspace_file_missing");
|
||||
const data = this.files.get(normalized);
|
||||
return { path: `/workspace/${normalized}`, bytes: encoded(data).byteLength, data_hash: fnv1a64(data) };
|
||||
}
|
||||
|
||||
list(prefix = "/workspace") {
|
||||
const normalized = normalizeWorkspacePath(prefix);
|
||||
return [...this.files.entries()].filter(([path]) => !normalized || path === normalized || path.startsWith(`${normalized}/`)).sort(([a], [b]) => a.localeCompare(b)).map(([path, data]) => ({ path: `/workspace/${path}`, bytes: encoded(data).byteLength, data_hash: fnv1a64(data) }));
|
||||
}
|
||||
|
||||
begin(operationId) {
|
||||
if (typeof operationId !== "string" || operationId.length === 0 || this.transactions.has(operationId)) throw new Error("workspace_transaction_invalid");
|
||||
this.transactions.set(operationId, new Map());
|
||||
return { schema: WORKSPACE_SCHEMA, operation_id: operationId, status: "prepared", state_hash: this.hash() };
|
||||
}
|
||||
|
||||
write(operationId, path, data) {
|
||||
const transaction = this.transactions.get(operationId);
|
||||
if (!transaction) throw new Error("workspace_transaction_missing");
|
||||
const normalized = normalizeWorkspacePath(path);
|
||||
if (!normalized) throw new Error("workspace_path_invalid");
|
||||
encoded(data);
|
||||
transaction.set(normalized, { kind: "write", data });
|
||||
return { operation_id: operationId, path: `/workspace/${normalized}`, bytes: encoded(data).byteLength };
|
||||
}
|
||||
|
||||
remove(operationId, path) {
|
||||
const transaction = this.transactions.get(operationId);
|
||||
if (!transaction) throw new Error("workspace_transaction_missing");
|
||||
const normalized = normalizeWorkspacePath(path);
|
||||
if (!normalized) throw new Error("workspace_path_invalid");
|
||||
transaction.set(normalized, { kind: "delete" });
|
||||
return { operation_id: operationId, path: `/workspace/${normalized}`, status: "deleted" };
|
||||
}
|
||||
|
||||
commit(operationId) {
|
||||
const transaction = this.transactions.get(operationId);
|
||||
if (!transaction) throw new Error("workspace_transaction_missing");
|
||||
const candidate = new Map(this.files);
|
||||
for (const [path, change] of [...transaction.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
if (change.kind === "delete") candidate.delete(path);
|
||||
else candidate.set(path, change.data);
|
||||
}
|
||||
this.assertQuota(candidate);
|
||||
this.files = candidate;
|
||||
this.transactions.delete(operationId);
|
||||
return { schema: WORKSPACE_SCHEMA, operation_id: operationId, status: "committed", state_hash: this.hash(), files: this.list() };
|
||||
}
|
||||
|
||||
abort(operationId) {
|
||||
if (!this.transactions.has(operationId)) throw new Error("workspace_transaction_missing");
|
||||
this.transactions.delete(operationId);
|
||||
return { schema: WORKSPACE_SCHEMA, operation_id: operationId, status: "aborted", state_hash: this.hash() };
|
||||
}
|
||||
|
||||
hash() { return fnv1a64(Object.fromEntries([...this.files.entries()].sort(([a], [b]) => a.localeCompare(b)))); }
|
||||
|
||||
export() { return { schema: WORKSPACE_SCHEMA, quota_bytes: this.quotaBytes, files: Object.fromEntries([...this.files.entries()].sort(([a], [b]) => a.localeCompare(b))), state_hash: this.hash() }; }
|
||||
|
||||
restore(value) {
|
||||
if (!value || value.schema !== WORKSPACE_SCHEMA || !value.files || value.state_hash !== fnv1a64(Object.fromEntries(Object.entries(value.files).sort(([a], [b]) => a.localeCompare(b))))) throw new Error("workspace_restore_invalid");
|
||||
const candidate = new WorkspaceRuntime({ quotaBytes: value.quota_bytes, files: value.files });
|
||||
this.quotaBytes = candidate.quotaBytes;
|
||||
this.files = candidate.files;
|
||||
this.transactions.clear();
|
||||
return this.export();
|
||||
}
|
||||
}
|
||||
6
web/workspace/sim-case-catalog.mjs
Normal file
6
web/workspace/sim-case-catalog.mjs
Normal file
File diff suppressed because one or more lines are too long
191
web/workspace/sim-case-loader.mjs
Normal file
191
web/workspace/sim-case-loader.mjs
Normal file
@@ -0,0 +1,191 @@
|
||||
import { fnv1a64 } from "../worker/replay.mjs";
|
||||
import { SIM_CASE_CATALOG, SIM_FILE_INDEX, SIM_HAL_COMPONENTS } from "./sim-case-catalog.mjs";
|
||||
|
||||
export const SIMULATION_CASE_SCHEMA = "simulation-case-bundle.v1";
|
||||
const RESOURCE_KEYS = new Set([
|
||||
"HAL", "HALFILE", "TOOLTABLE", "TOOL_TABLE", "VARFILE", "PARAMETER_FILE",
|
||||
"PROGRAM", "PROGRAMS", "PYTHON", "PYTHONFILE", "VCP", "VCPFILE", "CLPFILE",
|
||||
"CLASSICLADDER", "INTRO_GRAPHIC",
|
||||
]);
|
||||
const MANIFEST = new Set(SIM_FILE_INDEX);
|
||||
const PRELOADED_HAL_COMPONENTS = Object.freeze(["corexykins", "lineardeltakins", "motmod", "pid", "rotarydeltakins", "stepgen", "trivkins", "tpmod", "homemod"]);
|
||||
const CASE_KINEMATICS = Object.freeze({
|
||||
"linuxcnc/configs/sim/axis/corexy/corexy_by_kins.ini": Object.freeze({ type: "corexy", path_mode: "kins" }),
|
||||
"linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini": Object.freeze({ type: "corexy", path_mode: "hal" }),
|
||||
"linuxcnc/configs/sim/axis/ldelta.ini": Object.freeze({ type: "lineardelta", path_mode: "kins" }),
|
||||
"linuxcnc/configs/sim/axis/rdelta.ini": Object.freeze({ type: "rotarydelta", path_mode: "kins" }),
|
||||
});
|
||||
const RUNTIME_FIXTURES = Object.freeze({
|
||||
"linuxcnc/configs/sim/axis/minimal_xyz.ini": Object.freeze({
|
||||
generated_hal: "linuxcnc/configs/sim/axis/minimal_xyz_cmds.hal",
|
||||
replace: "HALFILE = LIB:basic_sim.tcl",
|
||||
with: "HALFILE = minimal_xyz_cmds.hal",
|
||||
reason: "LinuxCNC basic_sim.tcl generated HAL command fixture",
|
||||
}),
|
||||
"linuxcnc/configs/sim/axis/ja_tests/xz/xz.ini": Object.freeze({
|
||||
generated_hal: "linuxcnc/configs/sim/axis/ja_tests/xz/xz_cmds.hal",
|
||||
replace: "HALFILE = LIB:basic_sim.tcl",
|
||||
with: "HALFILE = xz_cmds.hal",
|
||||
reason: "LinuxCNC basic_sim.tcl generated XZ HAL command fixture",
|
||||
}),
|
||||
"linuxcnc/configs/sim/axis/ja_tests/xz/xz_both.ini": Object.freeze({
|
||||
generated_hal: "linuxcnc/configs/sim/axis/ja_tests/xz/xz_both_cmds.hal",
|
||||
replace: "HALFILE = LIB:basic_sim.tcl",
|
||||
with: "HALFILE = xz_both_cmds.hal",
|
||||
reason: "LinuxCNC basic_sim.tcl generated XZ BOTH HAL command fixture",
|
||||
}),
|
||||
"linuxcnc/configs/sim/axis/ja_tests/xz/xz_both_auto.ini": Object.freeze({
|
||||
generated_hal: "linuxcnc/configs/sim/axis/ja_tests/xz/xz_both_auto_cmds.hal",
|
||||
replace: "HALFILE = LIB:basic_sim.tcl",
|
||||
with: "HALFILE = xz_both_auto_cmds.hal",
|
||||
reason: "LinuxCNC basic_sim.tcl generated XZ BOTH AUTO HAL command fixture",
|
||||
}),
|
||||
});
|
||||
|
||||
function clone(value) { return structuredClone(value); }
|
||||
function text(value) { return typeof value === "string" && value.trim() ? value.trim() : null; }
|
||||
function stripQuotes(value) { return value.trim().replace(/^['"]|['"]$/g, ""); }
|
||||
function dirname(path) { const index = path.lastIndexOf("/"); return index < 0 ? "" : path.slice(0, index); }
|
||||
function normalize(path) {
|
||||
const parts = [];
|
||||
for (const part of path.replaceAll("\\", "/").split("/")) {
|
||||
if (!part || part === ".") continue;
|
||||
if (part === "..") { if (!parts.length) return null; parts.pop(); continue; }
|
||||
parts.push(part);
|
||||
}
|
||||
return parts.join("/");
|
||||
}
|
||||
function tokens(value) {
|
||||
return (value.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map(stripQuotes);
|
||||
}
|
||||
function candidates(source, raw, key = null, section = null) {
|
||||
const value = stripQuotes(raw);
|
||||
const first = tokens(value)[0] ?? value;
|
||||
if (String(key).toUpperCase() === "INTRO_GRAPHIC" && String(section).toUpperCase() === "DISPLAY" && first === "linuxcnc.gif") return ["linuxcnc/linuxcnc.gif"];
|
||||
if (first.startsWith("LIB:")) {
|
||||
const name = first.slice(4);
|
||||
if (!name || name.includes("..") || name.startsWith("/")) return [];
|
||||
const libraryCandidates = [`linuxcnc/lib/hallib/${name}`, `linuxcnc/lib/${name}`].map(normalize).filter(Boolean);
|
||||
const indexed = libraryCandidates.filter(inManifest);
|
||||
return indexed.length ? indexed : libraryCandidates;
|
||||
}
|
||||
if (["HAL", "HALFILE"].includes(String(key).toUpperCase()) && !first.includes("/") && !first.startsWith("linuxcnc/")) {
|
||||
const halCandidates = [normalize(`${dirname(source)}/${first}`), `linuxcnc/lib/hallib/${first}`, `linuxcnc/lib/${first}`].filter(Boolean);
|
||||
const indexed = halCandidates.filter(inManifest);
|
||||
return indexed.length ? indexed : halCandidates;
|
||||
}
|
||||
if (first.startsWith("linuxcnc/")) return [normalize(first)].filter(Boolean);
|
||||
return [normalize(`${dirname(source)}/${first}`)].filter(Boolean);
|
||||
}
|
||||
function includeCandidates(source, raw) {
|
||||
const value = stripQuotes(raw);
|
||||
if (value.startsWith("linuxcnc/")) return [normalize(value)].filter(Boolean);
|
||||
return [normalize(`${dirname(source)}/${value}`)].filter(Boolean);
|
||||
}
|
||||
function inManifest(path) { return MANIFEST.has(path); }
|
||||
function responseUrl(baseUrl, path) { return `${String(baseUrl ?? "").replace(/\/$/, "")}/${path}`.replace(/^\//, "/"); }
|
||||
|
||||
async function fetchText(fetchImpl, baseUrl, path) {
|
||||
const response = await fetchImpl(responseUrl(baseUrl, path));
|
||||
if (!response?.ok) return null;
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function resourceReferences(source, content) {
|
||||
const references = [];
|
||||
let section = "";
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const include = rawLine.trim().match(/^#?INCLUDE\s*(?:=\s*)?(.+)$/i);
|
||||
if (include) {
|
||||
references.push(...includeCandidates(source, include[1]));
|
||||
continue;
|
||||
}
|
||||
const line = rawLine.split("#", 1)[0].trim();
|
||||
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
|
||||
if (sectionMatch) { section = sectionMatch[1].trim(); continue; }
|
||||
const assignment = line.match(/^([^=:#]+?)\s*(?:=|:)\s*(.*)$/);
|
||||
if (assignment && RESOURCE_KEYS.has(assignment[1].trim().toUpperCase())) {
|
||||
const key = assignment[1].trim().toUpperCase();
|
||||
const values = tokens(assignment[2]);
|
||||
const resourceValues = key === "PROGRAMS" ? values : values.slice(0, 1);
|
||||
for (const value of resourceValues) references.push(...candidates(source, value, key, section));
|
||||
continue;
|
||||
}
|
||||
const halSource = line.match(/^source\s+(.+)$/i);
|
||||
if (halSource && !halSource[1].trim().startsWith("[")) references.push(...candidates(source, halSource[1], "HAL", section));
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
export function findSimulationCase(path) {
|
||||
return SIM_CASE_CATALOG.find((entry) => entry.path === path) ?? null;
|
||||
}
|
||||
|
||||
export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, baseUrl = "" } = {}) {
|
||||
if (typeof fetchImpl !== "function") throw new Error("simulation_case_fetch_required");
|
||||
const catalog = findSimulationCase(path);
|
||||
if (!catalog) throw new Error("simulation_case_unknown");
|
||||
const files = {};
|
||||
const fetched = [];
|
||||
const missing = [];
|
||||
const runtimeFixtures = [];
|
||||
const queue = [catalog.path];
|
||||
const queued = new Set(queue);
|
||||
while (queue.length) {
|
||||
const current = queue.shift();
|
||||
const content = await fetchText(fetchImpl, baseUrl, current);
|
||||
if (content === null) { missing.push(current); continue; }
|
||||
files[current] = content;
|
||||
fetched.push(current);
|
||||
for (const candidate of resourceReferences(current, content)) {
|
||||
if (!inManifest(candidate)) { missing.push(candidate); continue; }
|
||||
if (queued.has(candidate)) continue;
|
||||
queued.add(candidate);
|
||||
queue.push(candidate);
|
||||
}
|
||||
}
|
||||
const fixture = RUNTIME_FIXTURES[catalog.path];
|
||||
if (fixture) {
|
||||
if (!inManifest(fixture.generated_hal)) missing.push(fixture.generated_hal);
|
||||
else if (!files[fixture.generated_hal]) {
|
||||
const generated = await fetchText(fetchImpl, baseUrl, fixture.generated_hal);
|
||||
if (generated === null) missing.push(fixture.generated_hal);
|
||||
else { files[fixture.generated_hal] = generated; fetched.push(fixture.generated_hal); }
|
||||
}
|
||||
const targetPath = Object.keys(files).find((path) => files[path].includes(fixture.replace));
|
||||
const original = targetPath ? files[targetPath] : null;
|
||||
if (original && files[fixture.generated_hal]) {
|
||||
files[targetPath] = original.replace(fixture.replace, fixture.with);
|
||||
runtimeFixtures.push({ case_path: catalog.path, config_path: targetPath, generated_hal: fixture.generated_hal, reason: fixture.reason });
|
||||
}
|
||||
}
|
||||
const unsigned = {
|
||||
schema: SIMULATION_CASE_SCHEMA,
|
||||
entrypoint: catalog.path,
|
||||
files,
|
||||
fetched: [...fetched].sort(),
|
||||
missing: [...new Set(missing)].sort(),
|
||||
runtime_fixtures: runtimeFixtures,
|
||||
};
|
||||
return {
|
||||
...clone(unsigned),
|
||||
catalog,
|
||||
halManifest: [...new Set([...SIM_HAL_COMPONENTS, ...PRELOADED_HAL_COMPONENTS])].sort().map((component) => ({ component })),
|
||||
status: missing.length ? "blocked" : "ready",
|
||||
state_hash: fnv1a64(unsigned),
|
||||
};
|
||||
}
|
||||
|
||||
export function toWorkerConfiguration(bundle) {
|
||||
if (!bundle || bundle.schema !== SIMULATION_CASE_SCHEMA) throw new Error("simulation_case_bundle_invalid");
|
||||
return {
|
||||
entrypoint: bundle.entrypoint,
|
||||
files: clone(bundle.files),
|
||||
halManifest: clone(bundle.halManifest ?? []),
|
||||
state_hash: bundle.state_hash,
|
||||
runtime_fixtures: clone(bundle.runtime_fixtures ?? []),
|
||||
...(CASE_KINEMATICS[bundle.entrypoint] ? { kinematics: clone(CASE_KINEMATICS[bundle.entrypoint]) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export { CASE_KINEMATICS, PRELOADED_HAL_COMPONENTS, RESOURCE_KEYS, RUNTIME_FIXTURES };
|
||||
30
web/workspace/test_sim_case_loader.mjs
Normal file
30
web/workspace/test_sim_case_loader.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "./sim-case-loader.mjs";
|
||||
|
||||
const fetchImpl = async (url) => {
|
||||
const path = new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url);
|
||||
try {
|
||||
const body = await readFile(path);
|
||||
return { ok: true, text: async () => body.toString("utf8") };
|
||||
} catch {
|
||||
return { ok: false, text: async () => "" };
|
||||
}
|
||||
};
|
||||
|
||||
const ready = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini", fetchImpl });
|
||||
assert.equal(ready.status, "ready");
|
||||
assert.ok(ready.files["linuxcnc/configs/sim/axis/corexy/corexy_base.inc"]);
|
||||
assert.ok(ready.files["linuxcnc/configs/sim/axis/corexy/3joint_xyz.hal"]);
|
||||
assert.ok(ready.files["linuxcnc/configs/sim/axis/corexy/corexy_by_hal.hal"]);
|
||||
assert.equal(toWorkerConfiguration(ready).entrypoint, ready.entrypoint);
|
||||
assert.match(ready.state_hash, /^fnv1a64:/);
|
||||
|
||||
const blocked = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/anglejog/anglejog.ini", fetchImpl });
|
||||
assert.equal(blocked.status, "blocked");
|
||||
assert.ok(blocked.missing.includes("linuxcnc/configs/sim/axis/anglejog/anglejog.var"));
|
||||
assert.equal(blocked.catalog.status, "blocked");
|
||||
assert.equal((await loadSimulationCase({ path: ready.entrypoint, fetchImpl })).state_hash, ready.state_hash);
|
||||
assert.ok(ready.files["linuxcnc/configs/sim/axis/corexy/linuxcnc.var"]);
|
||||
assert.ok(ready.files["linuxcnc/configs/sim/axis/corexy/tool.tbl"]);
|
||||
console.log(JSON.stringify({ schema: "simulation-case-loader-sample.v1", status: "verified", case: { entrypoint: ready.entrypoint, files: ready.fetched.length, missing: ready.missing.length, state_hash: ready.state_hash }, blocked: { entrypoint: blocked.entrypoint, missing: blocked.missing.length, state_hash: blocked.state_hash } }));
|
||||
Reference in New Issue
Block a user