#!/usr/bin/env python3 """Deterministic file and simulation helper for the system-simulation skill. The script intentionally talks to the existing FastAPI service instead of importing solver internals. That keeps the skill boundary small and makes the backend the authority for component contracts, XML validation and simulation. """ from __future__ import annotations import argparse import codecs import difflib import hashlib import html import io import json import math import os import re import sys import time import urllib.error import urllib.parse import urllib.request import uuid import xml.etree.ElementTree as ET from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Iterable, Mapping, Sequence MAX_INPUT_BYTES = 5 * 1024 * 1024 DEFAULT_BASE_URL = "http://127.0.0.1:8000" DEFAULT_TIMEOUT_SECONDS = 30.0 MIN_TIMEOUT_SECONDS = 10.0 ALLOWED_BACKEND_HOSTS = {"127.0.0.1", "localhost", "::1"} SUPPORTED_FORMATS = {"json", "xml"} SIMULATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,128}$") XML_FORBIDDEN_DECLARATION = re.compile(br" None: super().__init__(message) self.code = code self.message = message self.exit_code = exit_code self.details = details class InputError(SkillCliError): def __init__(self, code: str, message: str, details: object | None = None) -> None: super().__init__(code, message, exit_code=2, details=details) class BackendError(SkillCliError): def __init__(self, code: str, message: str, details: object | None = None) -> None: super().__init__(code, message, exit_code=3, details=details) class SimulationError(SkillCliError): def __init__(self, code: str, message: str, details: object | None = None) -> None: super().__init__(code, message, exit_code=4, details=details) class ArtifactError(SkillCliError): def __init__(self, code: str, message: str, details: object | None = None) -> None: super().__init__(code, message, exit_code=5, details=details) @dataclass(frozen=True) class SourceFile: path: Path raw: bytes text: str encoding: str had_bom: bool sha256: str format: str parsed: object def emit_json(payload: object, *, stream: io.TextIOBase = sys.stdout) -> None: serialized = json.dumps(payload, ensure_ascii=False, allow_nan=False) + "\n" try: stream.write(serialized) stream.flush() except UnicodeEncodeError: binary_stream = getattr(stream, "buffer", None) if binary_stream is None: raise binary_stream.write(serialized.encode("utf-8")) binary_stream.flush() def configure_standard_streams() -> None: """Make structured JSON reliable on Windows terminals using legacy codepages.""" for stream in (sys.stdout, sys.stderr): reconfigure = getattr(stream, "reconfigure", None) if callable(reconfigure): reconfigure(encoding="utf-8", errors="backslashreplace") def stable_error_payload(error: SkillCliError) -> dict[str, object]: payload: dict[str, object] = { "ok": False, "error": { "code": error.code, "message": error.message, }, } if error.details is not None: payload["error"]["details"] = error.details # type: ignore[index] return payload def _sha256(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def _finite_json_constant(value: str) -> None: raise ValueError(f"JSON contains non-finite numeric constant {value}.") def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]: result: dict[str, object] = {} duplicates: list[str] = [] for key, value in pairs: if key in result: duplicates.append(key) result[key] = value if duplicates: names = ", ".join(sorted(set(duplicates))) raise ValueError(f"JSON contains duplicate object keys: {names}.") return result def parse_json_text(text: str) -> object: try: return json.loads( text, parse_constant=_finite_json_constant, object_pairs_hook=_unique_json_object, ) except (json.JSONDecodeError, ValueError) as exc: raise InputError("JSON_SYNTAX_INVALID", str(exc)) from exc def _decode_source(raw: bytes) -> tuple[str, str, bool]: if raw.startswith(codecs.BOM_UTF8): encoding, had_bom = "utf-8-sig", True elif raw.startswith(codecs.BOM_UTF16_LE): encoding, had_bom = "utf-16-le", True elif raw.startswith(codecs.BOM_UTF16_BE): encoding, had_bom = "utf-16-be", True else: encoding, had_bom = "utf-8", False try: text = raw.decode(encoding) except UnicodeDecodeError as exc: raise InputError( "TEXT_ENCODING_UNSUPPORTED", "Input must be UTF-8, UTF-8 with BOM, or BOM-marked UTF-16.", {"start": exc.start, "end": exc.end}, ) from exc if text.startswith("\ufeff"): text = text[1:] return text, encoding, had_bom def _content_format(text: str) -> str | None: stripped = text.lstrip() if stripped.startswith("{"): return "json" if stripped.startswith("<"): return "xml" return None def _suffix_format(path: Path) -> str | None: suffix = path.suffix.casefold() if suffix == ".json": return "json" if suffix == ".xml": return "xml" return None def _parse_xml(raw: bytes) -> ET.Element: # Removing NUL bytes also exposes ASCII declarations in BOM-marked UTF-16 # input without trying to interpret arbitrary encodings here. if XML_FORBIDDEN_DECLARATION.search(raw.replace(b"\x00", b"")): raise InputError( "XML_EXTERNAL_DECLARATION_FORBIDDEN", "DOCTYPE and ENTITY declarations are not accepted.", ) try: parser = ET.XMLParser( target=ET.TreeBuilder(insert_comments=True, insert_pis=True) ) return ET.fromstring(raw, parser=parser) except ET.ParseError as exc: details: dict[str, object] = {} if getattr(exc, "position", None): details["line"], details["column"] = exc.position raise InputError("XML_SYNTAX_INVALID", str(exc), details or None) from exc def load_source(path_text: str, requested_format: str = "auto") -> SourceFile: path = Path(path_text).expanduser() try: resolved = path.resolve(strict=True) except (FileNotFoundError, OSError) as exc: raise InputError("INPUT_FILE_NOT_FOUND", f"Input file was not found: {path}") from exc if not resolved.is_file(): raise InputError("INPUT_NOT_A_FILE", f"Input path is not a file: {resolved}") try: size = resolved.stat().st_size except OSError as exc: raise InputError("INPUT_FILE_UNREADABLE", str(exc)) from exc if size > MAX_INPUT_BYTES: raise InputError( "INPUT_FILE_TOO_LARGE", f"Input exceeds the {MAX_INPUT_BYTES}-byte safety limit.", {"sizeBytes": size, "maximumBytes": MAX_INPUT_BYTES}, ) try: raw = resolved.read_bytes() except OSError as exc: raise InputError("INPUT_FILE_UNREADABLE", str(exc)) from exc if not raw.strip(): raise InputError("INPUT_FILE_EMPTY", "Input file is empty.") text, encoding, had_bom = _decode_source(raw) detected = _content_format(text) if detected is None: raise InputError( "INPUT_FORMAT_UNRECOGNIZED", "Input content is neither a JSON object nor an XML document.", ) if requested_format != "auto" and requested_format != detected: raise InputError( "INPUT_FORMAT_MISMATCH", f"Requested {requested_format}, but the content looks like {detected}.", ) if detected == "json": parsed = parse_json_text(text) if not isinstance(parsed, dict): raise InputError("JSON_ROOT_NOT_OBJECT", "Project JSON root must be an object.") else: parsed = _parse_xml(raw) return SourceFile( path=resolved, raw=raw, text=text, encoding=encoding, had_bom=had_bom, sha256=_sha256(raw), format=detected, parsed=parsed, ) def validate_base_url(value: str) -> str: parsed = urllib.parse.urlparse(value) if parsed.scheme not in {"http", "https"} or not parsed.hostname: raise InputError("BACKEND_URL_INVALID", "Backend URL must be an HTTP(S) URL.") if parsed.username or parsed.password: raise InputError("BACKEND_URL_CREDENTIALS_FORBIDDEN", "Credentials are not allowed in the backend URL.") if parsed.query or parsed.fragment or parsed.params or parsed.path not in {"", "/"}: raise InputError( "BACKEND_URL_PATH_FORBIDDEN", "Backend URL must contain only scheme, loopback host and optional port.", ) if parsed.hostname.casefold() not in ALLOWED_BACKEND_HOSTS: raise InputError( "REMOTE_BACKEND_FORBIDDEN", "This base version only connects to a loopback backend.", {"host": parsed.hostname}, ) return value.rstrip("/") def _backend_url(base_url: str, path: str) -> str: return f"{base_url}{path}" def _decode_backend_error(data: bytes, status: int) -> object: try: parsed = json.loads(data.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): text = data.decode("utf-8", errors="replace").strip() return {"status": status, "message": text[:4000]} return {"status": status, "response": parsed} def http_request( base_url: str, path: str, *, method: str = "GET", body: bytes | None = None, content_type: str | None = None, timeout: float, headers: Mapping[str, str] | None = None, ) -> tuple[bytes, Mapping[str, str]]: request_headers = {"Accept": "application/json"} if content_type: request_headers["Content-Type"] = content_type if headers: request_headers.update(headers) request = urllib.request.Request( _backend_url(base_url, path), data=body, headers=request_headers, method=method, ) try: with urllib.request.urlopen(request, timeout=timeout) as response: return response.read(), dict(response.headers.items()) except urllib.error.HTTPError as exc: data = exc.read() raise BackendError( "BACKEND_HTTP_ERROR", f"Backend returned HTTP {exc.code}.", _decode_backend_error(data, exc.code), ) from exc except (urllib.error.URLError, TimeoutError, OSError) as exc: raise BackendError("BACKEND_UNAVAILABLE", str(exc)) from exc def http_json( base_url: str, path: str, *, method: str = "GET", payload: object | None = None, timeout: float, ) -> object: body = None content_type = None if payload is not None: try: body = json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8") except (TypeError, ValueError) as exc: raise InputError("REQUEST_JSON_INVALID", str(exc)) from exc content_type = "application/json" data, _ = http_request( base_url, path, method=method, body=body, content_type=content_type, timeout=timeout, ) try: return json.loads(data.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise BackendError( "BACKEND_RESPONSE_INVALID", "Backend response was not valid UTF-8 JSON.", ) from exc def _canonical_json(value: object) -> bytes: try: return ( json.dumps( value, ensure_ascii=False, allow_nan=False, indent=2, sort_keys=False, ) + "\n" ).encode("utf-8") except (TypeError, ValueError) as exc: raise InputError("JSON_VALUE_UNSUPPORTED", str(exc)) from exc def _canonical_xml(source: SourceFile) -> bytes: root = _parse_xml(source.raw) ET.indent(root, space=" ") rendered = ET.tostring( root, encoding="utf-8", xml_declaration=True, short_empty_elements=True, ) return rendered.rstrip(b"\r\n") + b"\n" def canonical_bytes(source: SourceFile) -> bytes: if source.format == "json": return _canonical_json(source.parsed) return _canonical_xml(source) def _line_ending_kind(text: str) -> str: crlf = text.count("\r\n") without_crlf = text.replace("\r\n", "") lf = without_crlf.count("\n") cr = without_crlf.count("\r") kinds = sum(count > 0 for count in (crlf, lf, cr)) if kinds > 1: return "mixed" if crlf: return "crlf" if cr: return "cr" return "lf" def text_normalization_report(source: SourceFile, canonical: bytes) -> dict[str, object]: issues: list[dict[str, object]] = [] if source.had_bom: issues.append( { "code": "TEXT_BOM_PRESENT", "message": "The normalized file uses UTF-8 without a BOM.", "repairable": True, } ) if source.encoding not in {"utf-8", "utf-8-sig"}: issues.append( { "code": "TEXT_ENCODING_NOT_UTF8", "message": f"The source encoding is {source.encoding}; normalized output uses UTF-8.", "repairable": True, } ) line_endings = _line_ending_kind(source.text) if line_endings != "lf": issues.append( { "code": "TEXT_LINE_ENDINGS_NOT_LF", "message": f"The source uses {line_endings} line endings; normalized output uses LF.", "repairable": True, } ) if not source.text.endswith(("\n", "\r")): issues.append( { "code": "TEXT_FINAL_NEWLINE_MISSING", "message": "The normalized file ends with one LF newline.", "repairable": True, } ) if source.raw != canonical and not issues: issues.append( { "code": "TEXT_LAYOUT_NOT_CANONICAL", "message": "Whitespace or serialization layout differs from the canonical form.", "repairable": True, } ) return { "needed": source.raw != canonical, "issues": issues, "canonicalSha256": _sha256(canonical), "canonicalSizeBytes": len(canonical), } def _json_request_bytes(value: object) -> bytes: try: return json.dumps(value, ensure_ascii=False, allow_nan=False).encode("utf-8") except (TypeError, ValueError) as exc: raise InputError("JSON_VALUE_UNSUPPORTED", str(exc)) from exc def _expect_mapping(value: object, *, code: str, message: str) -> dict[str, object]: if not isinstance(value, dict): raise BackendError(code, message, {"receivedType": type(value).__name__}) return value def _xml_inspection( source: SourceFile, *, base_url: str, timeout: float, ) -> tuple[dict[str, object], dict[str, object]]: parsed_raw, _ = http_request( base_url, "/api/system-xml/parse", method="POST", body=source.raw, content_type="application/xml", timeout=timeout, ) compiled_raw, _ = http_request( base_url, "/api/system-xml/compile-model", method="POST", body=source.raw, content_type="application/xml", timeout=timeout, ) try: parsed = _expect_mapping( json.loads(parsed_raw.decode("utf-8")), code="BACKEND_PARSE_RESPONSE_INVALID", message="XML parse response must be an object.", ) compiled = _expect_mapping( json.loads(compiled_raw.decode("utf-8")), code="BACKEND_COMPILE_RESPONSE_INVALID", message="XML compile response must be an object.", ) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise BackendError( "BACKEND_RESPONSE_INVALID", "XML inspection response was not valid JSON.", ) from exc model = _expect_mapping( parsed.get("model"), code="BACKEND_MODEL_MISSING", message="XML parse response did not contain a model object.", ) return model, compiled def _json_inspection( source: SourceFile, *, base_url: str, timeout: float, ) -> tuple[dict[str, object], dict[str, object]]: project = _expect_mapping( source.parsed, code="JSON_ROOT_NOT_OBJECT", message="Project JSON root must be an object.", ) raw, _ = http_request( base_url, "/api/reactflow/compile-model", method="POST", body=_json_request_bytes(project), content_type="application/json", timeout=timeout, ) try: compiled = _expect_mapping( json.loads(raw.decode("utf-8")), code="BACKEND_COMPILE_RESPONSE_INVALID", message="Project compile response must be an object.", ) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise BackendError( "BACKEND_RESPONSE_INVALID", "Project compile response was not valid JSON.", ) from exc return project, compiled def _result_variables(compiled: Mapping[str, object]) -> list[dict[str, object]]: result: list[dict[str, object]] = [] components = compiled.get("components") if not isinstance(components, list): return result for component in components: if not isinstance(component, dict): continue variables = component.get("resultVariables") if not isinstance(variables, list): continue for variable in variables: if isinstance(variable, dict): result.append(dict(variable)) return result def _collection_size(value: object) -> int: return len(value) if isinstance(value, (dict, list)) else 0 def _compiled_component_index( compiled: Mapping[str, object], ) -> dict[str, Mapping[str, object]]: components = compiled.get("components") if not isinstance(components, list): return {} return { str(component.get("id")): component for component in components if isinstance(component, dict) and component.get("id") is not None } def _compact_compiled_components( compiled: Mapping[str, object], ) -> list[dict[str, object]]: compact: list[dict[str, object]] = [] for component in _compiled_component_index(compiled).values(): variables = component.get("resultVariables") parameters = component.get("parameters") ports = component.get("ports") compact.append( { "id": component.get("id"), "type": component.get("type"), "label": component.get("label"), "modelVersion": component.get("modelVersion"), "parameterCount": _collection_size(parameters), "portCount": _collection_size(ports), "resultVariableCount": _collection_size(variables), "compiledForSimulation": True, } ) return compact def _compact_components( model: Mapping[str, object], compiled: Mapping[str, object], *, source_format: str, ) -> list[dict[str, object]]: """Merge source components with compiler metadata without dropping resources. Medium/property nodes intentionally do not appear in the executable network, but they are still part of the user's project and must remain visible in an inspection summary. """ compiled_by_id = _compiled_component_index(compiled) raw_components = model.get("nodes" if source_format == "json" else "components") if not isinstance(raw_components, list): return _compact_compiled_components(compiled) compact: list[dict[str, object]] = [] for raw_component in raw_components: if not isinstance(raw_component, dict): continue if source_format == "json": data = raw_component.get("data") source_data = data if isinstance(data, dict) else {} component_id = raw_component.get("id") component_type = ( source_data.get("componentType") or source_data.get("modelType") or raw_component.get("type") ) component_label = source_data.get("label") or raw_component.get("label") model_version = source_data.get("modelVersion") or raw_component.get("modelVersion") source_parameters = source_data.get("parameters") source_ports = source_data.get("ports") else: source_data = raw_component component_id = raw_component.get("id") component_type = raw_component.get("model_type") or raw_component.get("type") component_label = raw_component.get("label") model_version = raw_component.get("model_version") or raw_component.get("modelVersion") source_parameters = raw_component.get("parameters") source_ports = raw_component.get("ports") compiled_component = compiled_by_id.get(str(component_id)) compiled_parameters = ( compiled_component.get("parameters") if compiled_component is not None else None ) compiled_ports = ( compiled_component.get("ports") if compiled_component is not None else None ) variables = ( compiled_component.get("resultVariables") if compiled_component is not None else None ) compact.append( { "id": component_id, "type": component_type or (compiled_component.get("type") if compiled_component is not None else None), "label": component_label or (compiled_component.get("label") if compiled_component is not None else None), "modelVersion": model_version or ( compiled_component.get("modelVersion") if compiled_component is not None else None ), "parameterCount": _collection_size(source_parameters) or _collection_size(compiled_parameters), "portCount": _collection_size(source_ports) or _collection_size(compiled_ports), "resultVariableCount": _collection_size(variables), "compiledForSimulation": compiled_component is not None, } ) return compact def _page_metadata(total: int, offset: int, limit: int) -> dict[str, object]: returned = max(0, min(limit, total - offset)) has_more = offset + returned < total return { "offset": offset, "limit": limit, "total": total, "hasMore": has_more, "nextOffset": offset + returned if has_more else None, } def _component_type_counts(components: Sequence[Mapping[str, object]]) -> list[dict[str, object]]: counts: dict[str, int] = {} for component in components: model_type = str(component.get("type") or "unknown") counts[model_type] = counts.get(model_type, 0) + 1 return [ {"type": model_type, "count": count} for model_type, count in sorted(counts.items()) ] def _matches_variable_query(variable: Mapping[str, object], query: str | None) -> bool: if not query: return True needle = query.casefold() searchable = ( variable.get("key"), variable.get("componentId"), variable.get("componentType"), variable.get("portName"), variable.get("name"), variable.get("label"), variable.get("quantity"), variable.get("unit"), ) return any(needle in str(value).casefold() for value in searchable if value is not None) def _variable_page( variables: Sequence[dict[str, object]], *, query: str | None, offset: int, limit: int, ) -> tuple[list[dict[str, object]], dict[str, object]]: filtered = [variable for variable in variables if _matches_variable_query(variable, query)] items = filtered[offset : offset + limit] return items, { "query": query, **_page_metadata(len(filtered), offset, limit), } def _source_component_index( model: Mapping[str, object], *, source_format: str, ) -> dict[str, Mapping[str, object]]: raw_components = model.get("nodes" if source_format == "json" else "components") if not isinstance(raw_components, list): return {} return { str(component.get("id")): component for component in raw_components if isinstance(component, dict) and component.get("id") is not None } def _component_details( model: Mapping[str, object], compiled: Mapping[str, object], component_ids: Sequence[str], *, source_format: str, ) -> tuple[list[dict[str, object]], list[str]]: if not component_ids: return [], [] wanted = set(component_ids) source_by_id = _source_component_index(model, source_format=source_format) compiled_by_id = _compiled_component_index(compiled) details: list[dict[str, object]] = [] found: set[str] = set() for component_id in component_ids: source_component = source_by_id.get(component_id) compiled_component = compiled_by_id.get(component_id) if source_component is None and compiled_component is None: continue found.add(component_id) details.append( { "id": component_id, "compiledForSimulation": compiled_component is not None, "source": dict(source_component) if source_component is not None else None, "compiled": dict(compiled_component) if compiled_component is not None else None, } ) return details, sorted(wanted - found) def inspect_source( source: SourceFile, *, base_url: str, timeout: float, component_ids: Sequence[str] = (), variable_query: str | None = None, variable_offset: int = 0, variable_limit: int = 20, component_offset: int = 0, component_limit: int = 50, connection_offset: int = 0, connection_limit: int = 25, ) -> dict[str, object]: canonical = canonical_bytes(source) if source.format == "json": model, compiled = _json_inspection(source, base_url=base_url, timeout=timeout) nodes = model.get("nodes") if isinstance(model.get("nodes"), list) else [] edges = model.get("edges") if isinstance(model.get("edges"), list) else [] simulation = model.get("simulation") version: object = model.get("projectSchemaVersion") name: object = model.get("name", "untitled") component_count = len(nodes) connection_count = len(edges) else: model, compiled = _xml_inspection(source, base_url=base_url, timeout=timeout) components = model.get("components") if isinstance(model.get("components"), list) else [] connections = model.get("connections") if isinstance(model.get("connections"), list) else [] simulation = model.get("simulation") root = source.parsed version = root.get("schemaVersion") if isinstance(root, ET.Element) else None name = model.get("name", "untitled") component_count = len(components) connection_count = len(connections) extension_format = _suffix_format(source.path) source_issues: list[dict[str, object]] = [] if extension_format is not None and extension_format != source.format: source_issues.append( { "code": "FILE_EXTENSION_MISMATCH", "message": f"File extension suggests {extension_format}, but content is {source.format}.", "repairable": False, } ) backend_validation = compiled.get("validation") if isinstance(backend_validation, dict): backend_issues = backend_validation.get("issues") if isinstance(backend_issues, list): source_issues.extend( dict(issue) for issue in backend_issues if isinstance(issue, dict) ) all_variables = _result_variables(compiled) result_variables, variable_page = _variable_page( all_variables, query=variable_query, offset=variable_offset, limit=variable_limit, ) component_details, missing_component_details = _component_details( model, compiled, component_ids, source_format=source.format, ) compact_components = _compact_components( model, compiled, source_format=source.format, ) raw_connections = compiled.get("connections") connections = ( [dict(connection) for connection in raw_connections if isinstance(connection, dict)] if isinstance(raw_connections, list) else [] ) system_payload: dict[str, object] = { "name": name, "formatVersion": version, "simulation": simulation, "componentCount": component_count, "connectionCount": connection_count, "componentTypes": _component_type_counts(compact_components), "components": compact_components[component_offset : component_offset + component_limit], "componentPage": _page_metadata( len(compact_components), component_offset, component_limit ), "connections": connections[connection_offset : connection_offset + connection_limit], "connectionPage": _page_metadata( len(connections), connection_offset, connection_limit ), "unconnectedPorts": compiled.get("unconnectedPorts", []), "resultVariables": result_variables, "resultVariablePage": variable_page, } if component_ids: system_payload["componentDetails"] = component_details system_payload["missingComponentDetails"] = missing_component_details return { "ok": True, "command": "inspect", "source": { "path": str(source.path), "format": source.format, "sizeBytes": len(source.raw), "sha256": source.sha256, "encoding": source.encoding, "hadBom": source.had_bom, }, "validation": { "valid": True, "issues": source_issues, }, "textNormalization": text_normalization_report(source, canonical), "system": system_payload, } def command_inspect(args: argparse.Namespace) -> int: source = load_source(args.input, args.format) emit_json( inspect_source( source, base_url=args.base_url, timeout=args.timeout, component_ids=args.component, variable_query=args.variable_query, variable_offset=args.variable_offset, variable_limit=args.variable_limit, component_offset=args.component_offset, component_limit=args.component_limit, connection_offset=args.connection_offset, connection_limit=args.connection_limit, ) ) return 0 def _semantic_model( source: SourceFile, *, base_url: str, timeout: float, ) -> object: if source.format == "json": return source.parsed model, _ = _xml_inspection(source, base_url=base_url, timeout=timeout) return model def _normalized_preview(source: SourceFile, canonical: bytes) -> dict[str, object]: before = source.text.splitlines(keepends=True) after = canonical.decode("utf-8").splitlines(keepends=True) lines = list( difflib.unified_diff( before, after, fromfile=str(source.path), tofile=f"{source.path.name}.normalized", n=3, ) ) truncated = len(lines) > 200 diff_text = "".join(lines[:200]) if len(diff_text) > 20000: diff_text = diff_text[:20000] truncated = True return { "sourceSha256": source.sha256, "outputSha256": _sha256(canonical), "sourceSizeBytes": len(source.raw), "outputSizeBytes": len(canonical), "unifiedDiff": diff_text, "diffTruncated": truncated, } def _repair_confirmation_token( *, source_sha256: str, output_sha256: str, target: Path, ) -> str: binding = { "version": 1, "sourceSha256": source_sha256, "outputSha256": output_sha256, "targetPath": os.path.normcase(str(target)), } return _sha256( json.dumps(binding, ensure_ascii=False, sort_keys=True).encode("utf-8") ) def _ensure_distinct_new_output(source: Path, output_text: str) -> Path: output = Path(output_text).expanduser().resolve(strict=False) if output == source.resolve(strict=True): raise InputError("SOURCE_OVERWRITE_FORBIDDEN", "Output must not overwrite the source file.") if output.exists(): raise InputError("OUTPUT_ALREADY_EXISTS", f"Output already exists: {output}") return output def _write_new_file(path: Path, data: bytes) -> None: created = False try: path.parent.mkdir(parents=True, exist_ok=True) with path.open("xb") as handle: created = True handle.write(data) handle.flush() os.fsync(handle.fileno()) except OSError as exc: try: if created and path.exists(): path.unlink() except OSError: pass raise ArtifactError("OUTPUT_WRITE_FAILED", str(exc), {"path": str(path)}) from exc def command_repair_format(args: argparse.Namespace) -> int: source = load_source(args.input, args.format) if args.expected_sha256.casefold() != source.sha256: raise InputError( "SOURCE_HASH_MISMATCH", "The source changed after the repair preview; inspect it again.", {"expected": args.expected_sha256, "actual": source.sha256}, ) output = _ensure_distinct_new_output(source.path, args.output) canonical = canonical_bytes(source) inspection = inspect_source(source, base_url=args.base_url, timeout=args.timeout) preview = _normalized_preview(source, canonical) confirmation_token = _repair_confirmation_token( source_sha256=source.sha256, output_sha256=str(preview["outputSha256"]), target=output, ) preview["confirmationToken"] = confirmation_token report: dict[str, object] = { "ok": True, "command": "repair-format", "confirmed": bool(args.confirmed), "changed": source.raw != canonical, "source": inspection["source"], "targetPath": str(output), "textNormalization": inspection["textNormalization"], "preview": preview, } if source.raw == canonical: report["written"] = False report["message"] = "The source is already in canonical text form." emit_json(report) return 0 if not args.confirmed: report["written"] = False report["requiresConfirmation"] = True emit_json(report) return 0 if not args.confirmation_token: raise InputError( "REPAIR_CONFIRMATION_TOKEN_REQUIRED", "Confirmed repair requires the confirmation token returned by its preview.", ) if args.confirmation_token != confirmation_token: raise InputError( "REPAIR_CONFIRMATION_TOKEN_MISMATCH", "The repair preview no longer matches the source, normalized output, or target path.", ) if source.format == "json": candidate = SourceFile( path=output, raw=canonical, text=canonical.decode("utf-8"), encoding="utf-8", had_bom=False, sha256=_sha256(canonical), format="json", parsed=parse_json_text(canonical.decode("utf-8")), ) else: candidate = SourceFile( path=output, raw=canonical, text=canonical.decode("utf-8"), encoding="utf-8", had_bom=False, sha256=_sha256(canonical), format="xml", parsed=_parse_xml(canonical), ) before_model = _semantic_model(source, base_url=args.base_url, timeout=args.timeout) after_model = _semantic_model(candidate, base_url=args.base_url, timeout=args.timeout) if before_model != after_model: raise InputError( "NORMALIZATION_CHANGED_MODEL", "The candidate did not preserve the parsed model; no file was written.", ) _write_new_file(output, canonical) report["written"] = True report["output"] = { "path": str(output), "sha256": _sha256(canonical), "sizeBytes": len(canonical), } emit_json(report) return 0 def _prepare_output_directory(path_text: str) -> Path: path = Path(path_text).expanduser().resolve(strict=False) if path.exists(): if not path.is_dir(): raise InputError("OUTPUT_DIR_NOT_DIRECTORY", f"Output path is not a directory: {path}") try: if any(path.iterdir()): raise InputError( "OUTPUT_DIR_NOT_EMPTY", "Simulation output directory must be new or empty.", {"path": str(path)}, ) except OSError as exc: raise ArtifactError("OUTPUT_DIR_UNREADABLE", str(exc)) from exc else: try: path.mkdir(parents=True) except OSError as exc: raise ArtifactError("OUTPUT_DIR_CREATE_FAILED", str(exc)) from exc return path def _project_name(inspection: Mapping[str, object]) -> str: system = inspection.get("system") if isinstance(system, Mapping): name = system.get("name") if isinstance(name, str) and name.strip(): return name.strip() return "simulation" def _available_variables(inspection: Mapping[str, object]) -> dict[str, dict[str, object]]: system = inspection.get("system") if not isinstance(system, Mapping): return {} variables = system.get("resultVariables") if not isinstance(variables, list): return {} return { str(variable["key"]): dict(variable) for variable in variables if isinstance(variable, dict) and isinstance(variable.get("key"), str) } def _json_to_xml( source: SourceFile, *, base_url: str, timeout: float, ) -> bytes: raw, _ = http_request( base_url, "/api/reactflow/system-xml", method="POST", body=_json_request_bytes(source.parsed), content_type="application/json", timeout=timeout, ) if not raw.strip().startswith(b" dict[str, object] | None: try: value = http_json( base_url, f"/api/system-xml/simulations/{urllib.parse.quote(simulation_id, safe='')}", timeout=timeout, ) except BackendError: return None return value if isinstance(value, dict) else None def _public_result_event( event: Mapping[str, object], simulation_id: str, *, full_result: str | None = "result.json", ) -> dict[str, object]: """Return result completion metadata without echoing every time-series sample.""" result = event.get("result") if not isinstance(result, Mapping): return dict(event) variables = result.get("variables") series = result.get("series") times = series.get("time") if isinstance(series, Mapping) else None actual_end_time = result.get("actualEndTime") if actual_end_time is None: actual_end_time = result.get("simulatedUntil") summary: dict[str, object] = { "event": "result", "simulationId": event.get("simulationId") or simulation_id, "status": result.get("status"), "success": result.get("success"), "actualEndTime": actual_end_time, "sampleCount": len(times) if isinstance(times, list) else None, "resultVariableCount": len(variables) if isinstance(variables, list) else None, } if full_result is not None: summary["fullResult"] = full_result return summary def _should_emit_progress( event: Mapping[str, object], *, previous_phase: str | None, previous_progress: float | None, seconds_since_emit: float, ) -> bool: phase = str(event.get("phase") or "") raw_progress = event.get("progress") progress = ( float(raw_progress) if isinstance(raw_progress, (int, float)) and not isinstance(raw_progress, bool) else None ) return ( previous_phase is None or phase != previous_phase or (progress is not None and previous_progress is not None and progress >= previous_progress + 5.0) or progress == 100.0 or seconds_since_emit >= 5.0 ) def _read_simulation_stream( base_url: str, xml: bytes, simulation_id: str, timeout: float, progress_path: Path, *, event_sink: Callable[[object], None] | None = None, full_result: str | None = "result.json", ) -> tuple[dict[str, object] | None, dict[str, object] | None]: final_result: dict[str, object] | None = None final_error: dict[str, object] | None = None public_phase: str | None = None public_progress: float | None = None public_emit_time = 0.0 sink = event_sink or emit_json try: progress = progress_path.open("x", encoding="utf-8", newline="\n") except OSError as exc: raise ArtifactError( "PROGRESS_LOG_CREATE_FAILED", str(exc), {"path": str(progress_path)}, ) from exc try: response = _open_simulation_stream(base_url, xml, simulation_id, timeout) with response, progress: for raw_line in response: if not raw_line.strip(): continue try: event = json.loads(raw_line.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise SimulationError( "SIMULATION_EVENT_INVALID", "Simulation stream returned an invalid JSONL event.", ) from exc if not isinstance(event, dict) or event.get("event") not in { "progress", "result", "error", }: raise SimulationError( "SIMULATION_EVENT_INVALID", "Simulation stream returned an event outside the contract.", event, ) event_kind = event.get("event") logged_event = ( _public_result_event( event, simulation_id, full_result=full_result, ) if event_kind == "result" else event ) serialized = json.dumps(logged_event, ensure_ascii=False, allow_nan=False) progress.write(serialized + "\n") progress.flush() if event_kind == "progress": now = time.monotonic() if _should_emit_progress( event, previous_phase=public_phase, previous_progress=public_progress, seconds_since_emit=now - public_emit_time, ): sink(event) public_phase = str(event.get("phase") or "") raw_progress = event.get("progress") if isinstance(raw_progress, (int, float)) and not isinstance(raw_progress, bool): public_progress = float(raw_progress) public_emit_time = now elif event_kind == "result": sink(logged_event) else: sink(event) if event_kind == "result": result = event.get("result") if isinstance(result, dict): final_result = result else: final_error = { "code": "SIMULATION_RESULT_MISSING", "message": "Result event did not contain a result object.", } elif event_kind == "error": final_error = event except SkillCliError: progress.close() raise except (urllib.error.URLError, TimeoutError, OSError) as exc: snapshot = _recover_task_snapshot(base_url, simulation_id, timeout) if snapshot: result = snapshot.get("result") error = snapshot.get("error") if isinstance(result, dict): return result, error if isinstance(error, dict) else None if isinstance(error, dict): return None, error raise BackendError( "SIMULATION_STREAM_INTERRUPTED", str(exc), {"simulationId": simulation_id, "task": snapshot}, ) from exc return final_result, final_error def _validate_result_shape(result: Mapping[str, object]) -> tuple[list[dict[str, object]], dict[str, list[float]]]: raw_variables = result.get("variables") raw_series = result.get("series") if not isinstance(raw_variables, list) or not isinstance(raw_series, dict): raise SimulationError( "SIMULATION_RESULT_INVALID", "Simulation result does not contain variables and series.", ) variables = [dict(value) for value in raw_variables if isinstance(value, dict)] series: dict[str, list[float]] = {} for key, values in raw_series.items(): if not isinstance(key, str) or not isinstance(values, list): raise SimulationError( "SIMULATION_RESULT_INVALID", "Simulation series must map string keys to numeric arrays.", ) converted: list[float] = [] for value in values: if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(float(value)): raise SimulationError( "SIMULATION_RESULT_NONFINITE", f"Series {key} contains a non-finite or non-numeric value.", ) converted.append(float(value)) series[key] = converted times = series.get("time") if not times: raise SimulationError("SIMULATION_RESULT_HAS_NO_TIME", "Simulation produced no usable time series.") return variables, series def _variable_metadata_by_key(variables: Sequence[Mapping[str, object]]) -> dict[str, dict[str, object]]: return { str(variable["key"]): dict(variable) for variable in variables if isinstance(variable.get("key"), str) } def _write_result_json(path: Path, result: Mapping[str, object]) -> None: try: data = (json.dumps(result, ensure_ascii=False, allow_nan=False, indent=2) + "\n").encode("utf-8") except (TypeError, ValueError) as exc: raise SimulationError("SIMULATION_RESULT_NOT_JSON", str(exc)) from exc _write_new_file(path, data) def _download_csv( base_url: str, timeout: float, project_name: str, variables: Sequence[Mapping[str, object]], series: Mapping[str, Sequence[float]], ) -> bytes: raw, _ = http_request( base_url, "/api/simulation-results/csv", method="POST", body=_json_request_bytes( { "projectName": project_name, "variables": list(variables), "series": dict(series), } ), content_type="application/json", timeout=timeout, ) return raw def _safe_stem(value: str) -> str: value = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._") return value[:80] or "curve" def _decimated_points(times: Sequence[float], values: Sequence[float], maximum: int = 4000) -> list[tuple[float, float]]: count = min(len(times), len(values)) if count <= maximum: indices = range(count) else: indices = sorted({round(index * (count - 1) / (maximum - 1)) for index in range(maximum)}) return [(float(times[index]), float(values[index])) for index in indices] def _domain(values: Iterable[float]) -> tuple[float, float]: items = list(values) minimum = min(items) maximum = max(items) if minimum == maximum: padding = max(1.0, abs(minimum) * 0.05) return minimum - padding, maximum + padding padding = (maximum - minimum) * 0.05 return minimum - padding, maximum + padding def _polyline( points: Sequence[tuple[float, float]], *, x_min: float, x_max: float, y_min: float, y_max: float, left: float, top: float, width: float, height: float, ) -> str: x_span = x_max - x_min or 1.0 y_span = y_max - y_min or 1.0 return " ".join( f"{left + (x - x_min) * width / x_span:.2f},{top + height - (y - y_min) * height / y_span:.2f}" for x, y in points ) def _variable_label(variable: Mapping[str, object]) -> str: component = str(variable.get("componentId", "component")) port = variable.get("portName") label = str(variable.get("label") or variable.get("name") or variable.get("key")) return f"{component}.{port}.{label}" if port else f"{component}.{label}" def _svg_document( times: Sequence[float], selected: Sequence[tuple[Mapping[str, object], Sequence[float]]], *, stacked: bool, ) -> str: width = 1000 panel_height = 280 height = panel_height * len(selected) if stacked else 540 left, right = 82.0, 32.0 plot_width = width - left - right x_min, x_max = _domain(times) panels = list(enumerate(selected)) if stacked else [(0, selected[0])] parts = [ '', f'', '', '', ] if stacked: for panel_index, (variable, values) in panels: top = panel_index * panel_height + 34.0 plot_height = panel_height - 78.0 y_min, y_max = _domain(values) parts.extend(_svg_panel_axes(left, top, plot_width, plot_height, x_min, x_max, y_min, y_max)) points = _decimated_points(times, values) coords = _polyline(points, x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, left=left, top=top, width=plot_width, height=plot_height) label = html.escape(_variable_label(variable)) unit = html.escape(str(variable.get("unit", ""))) parts.append(f'{label} [{unit}]') parts.append(f'') else: top = 60.0 plot_height = height - 130.0 all_values = [value for _, values in selected for value in values] y_min, y_max = _domain(all_values) parts.extend(_svg_panel_axes(left, top, plot_width, plot_height, x_min, x_max, y_min, y_max)) for index, (variable, values) in enumerate(selected): coords = _polyline(_decimated_points(times, values), x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, left=left, top=top, width=plot_width, height=plot_height) color = CHART_COLORS[index % len(CHART_COLORS)] parts.append(f'') legend_y = 23 + index * 20 label = html.escape(_variable_label(variable)) parts.append(f'') parts.append(f'{label}') parts.append('') return "\n".join(parts) + "\n" def _svg_panel_axes( left: float, top: float, width: float, height: float, x_min: float, x_max: float, y_min: float, y_max: float, ) -> list[str]: parts: list[str] = [] for index in range(6): x = left + width * index / 5 y = top + height * index / 5 x_value = x_min + (x_max - x_min) * index / 5 y_value = y_max - (y_max - y_min) * index / 5 parts.append(f'') parts.append(f'') parts.append(f'{x_value:.5g}') parts.append(f'{y_value:.5g}') parts.append(f'') parts.append(f'') parts.append(f'time [s]') return parts def _write_charts( output_dir: Path, mode: str, selected_keys: Sequence[str], variables: Mapping[str, Mapping[str, object]], series: Mapping[str, Sequence[float]], ) -> list[str]: times = series["time"] selected = [(variables[key], series[key]) for key in selected_keys] artifacts: list[str] = [] if mode == "overlay": units = {str(variable.get("unit", "")) for variable, _ in selected} if len(units) > 1: raise InputError( "OVERLAY_UNITS_MISMATCH", "Overlay requires all selected result variables to use the same unit; use stacked instead.", {"units": sorted(units)}, ) path = output_dir / "curves-overlay.svg" _write_new_file(path, _svg_document(times, selected, stacked=False).encode("utf-8")) artifacts.append(str(path)) elif mode == "stacked": path = output_dir / "curves-stacked.svg" _write_new_file(path, _svg_document(times, selected, stacked=True).encode("utf-8")) artifacts.append(str(path)) else: for variable, values in selected: key = str(variable.get("key", "curve")) key_digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:8] path = output_dir / f"curve-{_safe_stem(key)}-{key_digest}.svg" _write_new_file(path, _svg_document(times, [(variable, values)], stacked=False).encode("utf-8")) artifacts.append(str(path)) return artifacts def command_simulate(args: argparse.Namespace) -> int: source = load_source(args.input, args.format) inspection = inspect_source( source, base_url=args.base_url, timeout=args.timeout, variable_limit=1_000_000, ) available = _available_variables(inspection) requested = list(dict.fromkeys(args.variables)) missing = [key for key in requested if key not in available] if missing: raise InputError( "RESULT_VARIABLE_UNKNOWN", "One or more requested result keys are not declared by the compiled model.", {"missing": missing, "available": sorted(available)}, ) if args.chart_mode == "overlay": units = {str(available[key].get("unit", "")) for key in requested} if len(units) > 1: raise InputError( "OVERLAY_UNITS_MISMATCH", "Overlay requires result variables with the same unit; choose stacked instead.", {"units": sorted(units)}, ) simulation_id = args.simulation_id or uuid.uuid4().hex if not SIMULATION_ID_PATTERN.fullmatch(simulation_id): raise InputError("SIMULATION_ID_INVALID", "Simulation ID must match [A-Za-z0-9._-]{1,128}.") output_dir = _prepare_output_directory(args.output_dir) xml = source.raw if source.format == "xml" else _json_to_xml(source, base_url=args.base_url, timeout=args.timeout) emit_json( { "event": "submitted", "simulationId": simulation_id, "input": str(source.path), "selectedVariables": requested, "chartMode": args.chart_mode, "outputDirectory": str(output_dir), } ) result, error = _read_simulation_stream( args.base_url, xml, simulation_id, args.timeout, output_dir / "progress.jsonl", ) if result is None: raise SimulationError( "SIMULATION_FAILED_WITHOUT_RESULT", "Simulation ended without a usable result.", {"simulationId": simulation_id, "backendError": error}, ) result_path = output_dir / "result.json" _write_result_json(result_path, result) variables, series = _validate_result_shape(result) metadata = _variable_metadata_by_key(variables) missing_after_run = [key for key in requested if key not in metadata or key not in series] if missing_after_run: raise SimulationError( "RESULT_VARIABLE_MISSING", "Requested variables were declared before the run but are missing from the result.", {"missing": missing_after_run}, ) csv_path = output_dir / "results.csv" csv_bytes = _download_csv( args.base_url, args.timeout, _project_name(inspection), variables, series, ) _write_new_file(csv_path, csv_bytes) chart_paths = _write_charts( output_dir, args.chart_mode, requested, metadata, series, ) status = str(result.get("status") or ("completed" if result.get("success") else "failed")) artifact_event = { "event": "artifacts", "simulationId": simulation_id, "status": status, "partial": status != "completed", "result": str(result_path), "csv": str(csv_path), "charts": chart_paths, "progress": str(output_dir / "progress.jsonl"), "selectedVariables": requested, } emit_json(artifact_event) return 0 if status == "completed" and bool(result.get("success")) else 4 def _public_task_snapshot(task: Mapping[str, object]) -> dict[str, object]: snapshot = {key: value for key, value in task.items() if key != "result"} result = task.get("result") if isinstance(result, Mapping): simulation_id = str(task.get("simulationId") or "unknown") snapshot["resultSummary"] = _public_result_event( {"event": "result", "result": result}, simulation_id, full_result=None, ) return snapshot def command_status(args: argparse.Namespace) -> int: simulation_id = args.simulation_id if not SIMULATION_ID_PATTERN.fullmatch(simulation_id): raise InputError("SIMULATION_ID_INVALID", "Simulation ID must match [A-Za-z0-9._-]{1,128}.") result = http_json( args.base_url, f"/api/system-xml/simulations/{urllib.parse.quote(simulation_id, safe='')}", timeout=args.timeout, ) task = _expect_mapping( result, code="BACKEND_TASK_RESPONSE_INVALID", message="Simulation task response must be an object.", ) emit_json({"ok": True, "command": "status", "task": _public_task_snapshot(task)}) return 0 def command_cancel(args: argparse.Namespace) -> int: simulation_id = args.simulation_id if not SIMULATION_ID_PATTERN.fullmatch(simulation_id): raise InputError("SIMULATION_ID_INVALID", "Simulation ID must match [A-Za-z0-9._-]{1,128}.") result = http_json( args.base_url, f"/api/system-xml/simulations/{urllib.parse.quote(simulation_id, safe='')}/cancel", method="POST", payload={"reason": args.reason}, timeout=args.timeout, ) emit_json({"ok": True, "command": "cancel", "task": result}) return 0 def positive_timeout(value: str) -> float: try: parsed = float(value) except ValueError as exc: raise argparse.ArgumentTypeError("timeout must be numeric") from exc if not math.isfinite(parsed) or parsed < MIN_TIMEOUT_SECONDS: raise argparse.ArgumentTypeError( f"timeout must be finite and at least {MIN_TIMEOUT_SECONDS:g} seconds " "so it remains above the backend heartbeat interval" ) return parsed def nonnegative_integer(value: str) -> int: try: parsed = int(value) except ValueError as exc: raise argparse.ArgumentTypeError("value must be an integer") from exc if parsed < 0: raise argparse.ArgumentTypeError("value must be zero or greater") return parsed def page_limit(value: str) -> int: parsed = nonnegative_integer(value) if parsed < 1 or parsed > 200: raise argparse.ArgumentTypeError("page limit must be between 1 and 200") return parsed def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Inspect, safely normalize and simulate SystemSimulationApp JSON/XML files." ) parser.add_argument( "--base-url", default=DEFAULT_BASE_URL, help="Loopback FastAPI base URL (default: %(default)s).", ) parser.add_argument( "--timeout", default=DEFAULT_TIMEOUT_SECONDS, type=positive_timeout, help="HTTP/heartbeat read timeout in seconds (default: %(default)s).", ) subparsers = parser.add_subparsers(dest="command", required=True) inspect_parser = subparsers.add_parser("inspect", help="Validate and summarize a project file.") inspect_parser.add_argument("input") inspect_parser.add_argument("--format", choices=("auto", "json", "xml"), default="auto") inspect_parser.add_argument( "--component", action="append", default=[], help="Include full compiled details for this component ID (repeatable).", ) inspect_parser.add_argument( "--variable-query", help="Filter result variables by key, component, label, quantity or unit.", ) inspect_parser.add_argument( "--variable-offset", type=nonnegative_integer, default=0, help="Result-variable page offset (default: %(default)s).", ) inspect_parser.add_argument( "--variable-limit", type=page_limit, default=20, help="Result-variable page size, 1-200 (default: %(default)s).", ) inspect_parser.add_argument( "--component-offset", type=nonnegative_integer, default=0, help="Component-summary page offset (default: %(default)s).", ) inspect_parser.add_argument( "--component-limit", type=page_limit, default=50, help="Component-summary page size, 1-200 (default: %(default)s).", ) inspect_parser.add_argument( "--connection-offset", type=nonnegative_integer, default=0, help="Connection page offset (default: %(default)s).", ) inspect_parser.add_argument( "--connection-limit", type=page_limit, default=25, help="Connection page size, 1-200 (default: %(default)s).", ) inspect_parser.set_defaults(handler=command_inspect) repair_parser = subparsers.add_parser( "repair-format", help="Preview or write a semantics-preserving UTF-8/LF normalization.", ) repair_parser.add_argument("input") repair_parser.add_argument("--format", choices=("auto", "json", "xml"), default="auto") repair_parser.add_argument("--output", required=True) repair_parser.add_argument("--expected-sha256", required=True) repair_parser.add_argument( "--confirmation-token", help="Token from the matching preview; required together with --confirmed.", ) repair_parser.add_argument( "--confirmed", action="store_true", help="Write the new file. Without this flag, only return a diff preview.", ) repair_parser.set_defaults(handler=command_repair_format) simulate_parser = subparsers.add_parser("simulate", help="Run and monitor a simulation.") simulate_parser.add_argument("input") simulate_parser.add_argument("--format", choices=("auto", "json", "xml"), default="auto") simulate_parser.add_argument("--output-dir", required=True) simulate_parser.add_argument("--variables", nargs="+", required=True) simulate_parser.add_argument( "--chart-mode", choices=("separate", "overlay", "stacked"), required=True, ) simulate_parser.add_argument("--simulation-id") simulate_parser.set_defaults(handler=command_simulate) status_parser = subparsers.add_parser("status", help="Read a known simulation task.") status_parser.add_argument("simulation_id") status_parser.set_defaults(handler=command_status) cancel_parser = subparsers.add_parser("cancel", help="Cancel a known simulation task.") cancel_parser.add_argument("simulation_id") cancel_parser.add_argument("--reason", choices=("user", "stalled"), default="user") cancel_parser.set_defaults(handler=command_cancel) return parser def main(argv: Sequence[str] | None = None) -> int: configure_standard_streams() parser = build_parser() args = parser.parse_args(argv) try: args.base_url = validate_base_url(args.base_url) return int(args.handler(args)) except SkillCliError as exc: emit_json(stable_error_payload(exc), stream=sys.stderr) return exc.exit_code except KeyboardInterrupt: emit_json( stable_error_payload( SimulationError( "CLIENT_INTERRUPTED", "The local monitor was interrupted. The backend task may still be running; query its simulation ID.", ) ), stream=sys.stderr, ) return 4 if __name__ == "__main__": raise SystemExit(main())