Implement AXIS parity UI and execution baseline
This commit is contained in:
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) };
|
||||
}
|
||||
Reference in New Issue
Block a user