Implement AXIS parity UI and execution baseline

This commit is contained in:
2026-08-23 07:28:37 +08:00
parent 1325504544
commit 06f96f85e5
463 changed files with 84893 additions and 692 deletions

View 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 };

View 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;

View File

@@ -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";

View File

@@ -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";

View 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) };
}

View File

@@ -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,

View File

@@ -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) => {

View 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" };
}
}

View 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
View 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();
}
}

File diff suppressed because one or more lines are too long

View 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 };

View 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 } }));