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" }); export const RUNTIME_RESOURCE_KEYS = Object.freeze(["PARAMETER_FILE", "VARFILE", "TOOL_TABLE", "TOOLTABLE"]); export const OPTIONAL_RESOURCE_KEYS = Object.freeze(["INTRO_GRAPHIC"]); function clone(value) { return structuredClone(value); } function blocked(code, source, line, extra = {}) { return { status: "blocked", code, source, line, ...extra }; } export function runtimeResourceFixture({ key = null, type = null, path = null } = {}) { const normalizedKey = String(key ?? "").toUpperCase(); const normalizedType = String(type ?? "").toLowerCase(); const extension = typeof path === "string" ? path.toLowerCase().split(".").pop() : null; if ((["PARAMETER_FILE", "VARFILE"].includes(normalizedKey) || normalizedType === "parameter_file") && extension === "var") { return { kind: "parameter_file", content: "", reason: "LinuxCNC restores a missing parameter file as zeroed defaults and creates it on exit" }; } if ((["TOOL_TABLE", "TOOLTABLE"].includes(normalizedKey) || normalizedType === "tool_table") && extension === "tbl") { return { kind: "tool_table", content: "", reason: "LinuxCNC keeps an empty tool table when the configured table cannot be opened" }; } return null; } function optionalIntroGraphic({ key = null, section = null, type = null } = {}) { return String(key ?? "").toUpperCase() === "INTRO_GRAPHIC" && String(type ?? "").toLowerCase() === "image" && String(section ?? "").toUpperCase() === "DISPLAY"; } function resolveWorkspaceRelative(basePath, value) { const parts = basePath.split("/"); parts.pop(); for (const part of value.replaceAll("\\", "/").split("/")) { if (!part || part === ".") continue; if (part === "..") { if (!parts.length) throw new Error("resource_path_escape"); parts.pop(); continue; } parts.push(part); } return normalizeWorkspacePath(`/workspace/${parts.join("/")}`); } 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"); return resolveWorkspaceRelative(base, value); } function pathCandidates(base, raw, key = null, section = null) { if (typeof raw !== "string") throw new Error("resource_path_empty"); const value = (raw.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.[0] ?? 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 [normalizeWorkspacePath(`/workspace/linuxcnc/lib/hallib/${libraryName}`)]; } if (["HAL", "HALFILE"].includes(normalizedKey) && !value.includes("/") && !value.startsWith("linuxcnc/")) { return [pathOf(base, value), `linuxcnc/lib/hallib/${value}`].map((candidate) => normalizeWorkspacePath(`/workspace/${candidate}`)); } return [pathOf(base, value)]; } function inferType(path) { if (typeof path !== "string" || !path) return null; 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 generatedFiles = new Map(); 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; } let existing = candidates.filter((candidate) => { try { workspace.read(`/workspace/${candidate}`); return true; } catch { return false; } }); if (existing.length > 1 && ["HAL", "HALFILE"].includes(String(key ?? "").toUpperCase())) existing = [existing[0]]; 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 }); if (existing.length === 0) { const generated = runtimeResourceFixture({ key, type: actualType, path }); if (generated) { generatedFiles.set(path, generated.content); resources.push({ status: "resolved", resource_id: id, type: actualType, path, source, line, key: key ?? null, bytes: new TextEncoder().encode(generated.content).byteLength, data_hash: fnv1a64(generated.content), runtime_generated: true, runtime_kind: generated.kind, runtime_reason: generated.reason, }); return; } if (optionalIntroGraphic({ key, section, type: actualType })) { resources.push({ status: "optional", code: "resource_optional_missing", resource_id: id, type: actualType, path, source, line, key: key ?? null, reason: "LinuxCNC ignores a missing DISPLAY.INTRO_GRAPHIC during startup", }); return; } } 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) continue; const values = entry.key.toUpperCase() === "PROGRAMS" ? entry.value.split(/\s*,\s*|\s+/).filter(Boolean) : [entry.value]; for (const raw of values) 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 === "blocked"); const optionalRows = sorted.filter((row) => row.status === "optional"); const result = { schema: RESOURCE_GRAPH_SCHEMA, entrypoint: parsed.entrypoint, ini_hash: parsed.state_hash, resources: sorted, generated_files: Object.fromEntries([...generatedFiles.entries()].sort(([left], [right]) => left.localeCompare(right))), resolved_count: sorted.filter((row) => row.status === "resolved").length, optional_count: optionalRows.length, blocked_count: blockedRows.length, status: blockedRows.length ? "blocked" : "ready", }; return { ...result, state_hash: fnv1a64(result) }; }