From 48da6be21c7b4c6ea5ea8205b16f2510e91bae08 Mon Sep 17 00:00:00 2001
From: ljz <425868052@qq.com>
Date: Thu, 3 Sep 2026 16:15:44 +0800
Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=9F=BA=E7=A1=80=E7=89=88?=
=?UTF-8?q?=E7=B3=BB=E7=BB=9F=E4=BB=BF=E7=9C=9F=20Skill?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
skills/system-simulation/SKILL.md | 40 +
skills/system-simulation/agents/openai.yaml | 7 +
.../references/file-contracts.md | 95 +
.../references/repair-policy.md | 48 +
.../system-simulation/references/workflows.md | 142 ++
.../scripts/simulation_skill.py | 1933 +++++++++++++++++
tests/test_system_simulation_skill.py | 1068 +++++++++
7 files changed, 3333 insertions(+)
create mode 100644 skills/system-simulation/SKILL.md
create mode 100644 skills/system-simulation/agents/openai.yaml
create mode 100644 skills/system-simulation/references/file-contracts.md
create mode 100644 skills/system-simulation/references/repair-policy.md
create mode 100644 skills/system-simulation/references/workflows.md
create mode 100644 skills/system-simulation/scripts/simulation_skill.py
create mode 100644 tests/test_system_simulation_skill.py
diff --git a/skills/system-simulation/SKILL.md b/skills/system-simulation/SKILL.md
new file mode 100644
index 0000000..cf96c3d
--- /dev/null
+++ b/skills/system-simulation/SKILL.md
@@ -0,0 +1,40 @@
+---
+name: system-simulation
+description: 读取、校验并简要解释 SystemSimulationApp 工程 JSON v1 或 System XML v3,安全规范化文件文本,并在用户选定结果曲线后启动、监视或取消仿真及导出 CSV。适用于检查模型文件、修复编码或换行、运行仿真和获取结果;不用于旧格式迁移、任意语义修复、自动迭代或网页自动预装。
+---
+
+# 系统仿真
+
+使用本 Skill 随附的确定性脚本检查模型、调用现有后端并保存结果;不要让语言模型自行重写模型或猜测求解数据。
+
+## 基本边界
+
+- 仅处理 ReactFlow 工程 JSON v1 和 System XML v3。版本缺失、不受支持或模型版本不匹配时,说明问题并停止,不进行迁移猜测。
+- 组件参数是仿真前设定的固定输入;结果变量才是可随时间绘制的量。不要把“参数”当成结果曲线。
+- 文件通过格式校验不等于物理系统一定可求解。不要隐瞒编译或运行阶段的诊断。
+- 不直接覆盖源文件,不自行修改参数、连接、组件类型、模型版本或求解设置。
+- 本版不支持把模型自动注入网页、生成可直接打开的预装页面、任意损坏文件修复、模型迁移或自动调参迭代。明确告知用户这些能力尚未实现,不要用手工网页操作冒充支持。
+
+处理文件、解释格式或选择结果变量时,读取 [references/file-contracts.md](references/file-contracts.md)。请求文件修复时,再读取 [references/repair-policy.md](references/repair-policy.md)。需要运行、监视、取消仿真或交付结果时,读取 [references/workflows.md](references/workflows.md)。
+
+## 工作原则
+
+1. 先用 `inspect` 确认输入格式、版本、结构和诊断,再基于检查结果简要解释组件、连接与仿真设置。
+2. 如果用户要求修复,只能执行文本规范化。先展示预览和源文件 SHA-256,获得针对该预览的明确确认后,才可写入另一个输出路径;随后重新 `inspect`。
+3. 仿真前必须让用户选择直接曲线查看方式,并解析具体结果变量:
+ - 分别查看所选变量;
+ - 将多个同单位、可比较的变量叠加;
+ - 将不同物理量或单位的变量上下排列。
+4. 用户用显示名称描述组件或变量时,利用检查结果中的稳定 ID、结果 `key`、物理量和单位消歧。存在重名、多个候选或“参数/结果变量”含义不清时,先询问,不能替用户猜。
+5. 使用 `simulate` 的事件流持续判断 queued、validating、compiling、integrating 和结束状态。仿真时间暂时不变但内部活动仍增长时,只说明正在处理慢步,不能宣称卡死。
+6. 成功运行后交付用户选择的 SVG 曲线和完整 `results.csv`,并简要说明完成状态、实际仿真终点和重要诊断。失败或取消时交付能够安全生成的部分结果;若运行前即失败而没有 CSV,要明确说明原因。
+
+脚本命令统一从仓库根目录运行:
+
+```powershell
+py -3.12 skills/system-simulation/scripts/simulation_skill.py --help
+```
+
+Windows 优先使用仓库 `.venv-win\Scripts\python.exe`(若存在),否则使用 `py -3.12`;Linux 优先使用 `.venv/bin/python`,否则使用 `python3.12`。不要调用未经版本确认的 `python`,本项目要求 Python 3.12。
+
+优先依赖脚本返回的结构化 JSON/JSONL、稳定错误码和退出码做判断,不解析中文提示文本来驱动下一步。
diff --git a/skills/system-simulation/agents/openai.yaml b/skills/system-simulation/agents/openai.yaml
new file mode 100644
index 0000000..171568c
--- /dev/null
+++ b/skills/system-simulation/agents/openai.yaml
@@ -0,0 +1,7 @@
+interface:
+ display_name: "系统仿真文件助手"
+ short_description: "读取与校验模型文件,监视仿真并导出结果曲线和 CSV 文件"
+ default_prompt: "使用 $system-simulation 检查我的模型文件,并在我选定结果曲线后运行和监视仿真。"
+
+policy:
+ allow_implicit_invocation: true
diff --git a/skills/system-simulation/references/file-contracts.md b/skills/system-simulation/references/file-contracts.md
new file mode 100644
index 0000000..2ced179
--- /dev/null
+++ b/skills/system-simulation/references/file-contracts.md
@@ -0,0 +1,95 @@
+# 文件合同与解释规则
+
+## 支持范围
+
+本 Skill 只接受以下两种当前格式:
+
+| 格式 | 版本标志 | 用途 |
+| --- | --- | --- |
+| ReactFlow 工程 JSON | 顶层 `projectSchemaVersion: 1` | 保存组件、画布、端口显示快照、连线和仿真设置,适合继续编辑 |
+| System XML | 根元素 `System/@schemaVersion="3"` 且 `unitSystem="SI"` | 保存可执行模型,适合校验、编译和求解 |
+
+默认让 `inspect --format auto` 根据内容和扩展名识别格式。若内容与扩展名不一致、无法唯一识别或用户明确指定格式,则报告实际证据,不悄悄按另一种格式解释。
+
+System XML v1/v2、缺少 `projectSchemaVersion` 的旧工程、字符串端口和不匹配的组件 `modelVersion` 均不属于本 Skill 的迁移范围。不能只改版本号使其看似当前格式。
+
+## 工程 JSON v1
+
+顶层合同为:
+
+```text
+projectSchemaVersion = 1
+name
+nodes[]
+edges[]
+simulation { t_start, t_stop, step, max_step, method }
+```
+
+重要规则:
+
+- 节点的 `id` 是实例稳定标识;显示标签不能替代它。
+- `data.modelType` 标识注册模型,`data.modelVersion` 必须与当前组件目录精确匹配,执行前不得自动补成当前版本。
+- `data.parameters` 保存输入值;`parameterUnits`、科学计数法偏好、坐标、旋转和镜像属于编辑显示信息。
+- 连接必须保留两端组件及 Handle。不能根据节点位置猜测缺失端口。
+- `simulation.step` 是结果采样间隔;`max_step` 是求解器内部步长上限,两者不能混用。
+
+工程 JSON 可以导出为 System XML v3,但转换后不会保留全部画布显示信息的对等逆转换合同。
+
+## System XML v3
+
+XML v3 只描述“求解什么”:
+
+- 每个 `Component` 必须有唯一 `id`、注册 `type`、精确 `modelVersion` 和完整 SI 参数;
+- 每条连接由两个 `Endpoint(component, port)` 组成;端口类型、方向和物理合同由后端注册表恢复;
+- `Simulation/@sampleStep` 对应工程 JSON 的 `simulation.step`;
+- 不保存组件位置、旋转、镜像、显示单位或端口显示快照;
+- 当前后端固定按 v3 校验,不会根据文件内容选择旧解析器。
+
+XML 校验依次覆盖安全/语法、XSD 和语义层。通过这些检查后,编译和求解仍可能发现未连接端口、缺少储能锚点、方程结构或数值问题。
+
+## 简要解释模型
+
+解释必须依据 `inspect` 的结构化输出以及组件目录,而不是仅凭组件名称推测。优先说明:
+
+1. 文件格式、版本和项目名;
+2. 仿真起止时间、采样间隔、最大内部步长和算法;
+3. 组件数量、稳定 ID、模型类型和主要输入参数;
+4. 连接数量、连接端点及能够确定的物理域;
+5. 错误、警告,以及它们属于格式、语义、编译还是运行阶段。
+
+保持“文件合同正确”和“物理模型合理”两个结论分开。没有组件文档或注册元数据支持时,不声称某个参数具有推测出的物理效果。
+
+## 参数与结果变量
+
+必须明确区分:
+
+- **参数**:仿真开始前设定的固定输入,例如质量、初始压力、摩擦选项;通常没有时间序列。
+- **结果变量**:仿真返回的时间序列,例如位移、速度、压力或流量;只有这类量可以选作曲线。
+
+选择曲线时以结果元数据为准,至少核对:
+
+```text
+key + componentId + componentType + label/quantity + unit
+```
+
+稳定 `key` 是传给 `simulate --variables` 的最终标识。用户只说“质量块的速度”而存在多个质量块,或一个组件存在多个符合描述的速度结果时,列出候选的组件 ID、结果名称和单位,请用户消歧。
+
+`inspect` 默认对组件摘要、连接和结果变量分页。先读取 `componentTypes` 了解完整模型的组件类型分布,再根据 `componentPage`、`connectionPage` 或 `resultVariablePage` 的 `nextOffset` 翻页。优先使用 `--variable-query` 按组件 ID、标签、物理量或单位缩小范围;只有用户点名组件时才使用 `--component` 读取该组件的完整源数据和可用的编译合同。
+
+组件摘要中的 `compiledForSimulation` 表示该节点是否进入动态求解网络。介质/物性配置节点仍属于工程,因此会保留在组件总数和列表中,但通常标记为 `false`;这不表示组件丢失或编译失败。
+
+曲线模式约束:
+
+- `separate`:每个所选结果变量分别成图;
+- `overlay`:只叠加单位相同且含义可比较的结果变量;
+- `stacked`:不同物理量或不同单位上下排列,避免共用一个纵轴造成误读。
+
+本版运行 `simulate` 时必须指定至少一个 `--variables` 稳定键,避免在大型模型上无意生成成百上千张曲线。完整 CSV 仍包含全部可用结果变量。
+
+## 权威来源
+
+- 工程 JSON 请求合同:`app/main.py` 中的 `ReactFlowProjectPayload`
+- 组件目录:`GET /api/components/catalog`
+- XML v3:`schemas/system-simulation-v3.xsd`、`docs/standard/system-xml-v3.md`
+- 接口边界:`docs/standard/backend-interface-version-spec-v1.md`
+- 结果变量:组件注册合同中的 `RESULT_VARIABLES` 及仿真结果元数据
diff --git a/skills/system-simulation/references/repair-policy.md b/skills/system-simulation/references/repair-policy.md
new file mode 100644
index 0000000..5616fb4
--- /dev/null
+++ b/skills/system-simulation/references/repair-policy.md
@@ -0,0 +1,48 @@
+# 安全文本规范化策略
+
+## 目的
+
+`repair-format` 只解决可解析 JSON v1 或 XML v3 的文本层问题,使文件采用稳定的 UTF-8 和跨平台文本格式。它不是模型迁移器,也不是语义修复器。
+
+## 允许的修改
+
+仅允许脚本已经证明不会改变解析后数据合同的规范化,例如:
+
+- 将可安全解码的输入统一写为 UTF-8;
+- 统一 BOM 和换行表现;
+- 规范化文件末尾换行;
+- 对可解析内容采用脚本规定的稳定文本序列化形式。
+
+以脚本返回的预览、变更摘要和哈希为准;不要在脚本外另写正则替换或自制格式化器。若文件连语法都无法可靠解析,停止并报告诊断,不能尝试猜测闭合括号、XML 标签或截断内容。
+
+## 禁止的修改
+
+本版不得自动执行下列动作:
+
+- 新增、删除、更换或重命名组件和连接;
+- 修改组件 ID、类型、端口、`modelVersion` 或 Schema 版本;
+- 填猜缺失参数、改变数值、单位、离散选项或介质引用;
+- 修改仿真起止时间、采样间隔、最大步长或求解算法;
+- 把 XML v1/v2 或旧工程升级到当前版本;
+- 根据报错放宽容差、删除失败组件或改变物理拓扑;
+- 覆盖源文件,即使用户给出的输出路径通过大小写、相对路径或符号链接指向源文件也不行。
+
+发现上述问题时,可以解释和给出人工处理建议,但不能借“修复格式”的名义实施。
+
+## 强制确认流程
+
+1. 对源文件运行 `inspect`,记录格式、诊断和 SHA-256。
+2. 生成或读取 `repair-format` 的规范化预览,向用户说明只会改变哪些文本表现,并展示目标输出路径。
+3. 等待用户针对该预览明确确认。笼统的“帮我看看”或先前对其他版本的确认不能复用。
+4. 使用同一个源文件 SHA-256、预览返回的 `confirmationToken`、`--confirmed` 和预览中相同的 `--output` 路径执行写入。token 绑定源哈希、规范化输出哈希和目标绝对路径。
+5. 如果哈希、规范化结果或目标路径已变化,停止并重新预览;不能绕过 `--expected-sha256` 或确认 token。
+6. 对输出文件重新运行 `inspect`。只有重新校验通过且解析后的模型语义未改变时,才能报告完成。
+
+示例命令形状见 [workflows.md](workflows.md)。
+
+## 输出与交付
+
+- 输出名称建议为原名加 `.normalized`,例如 `plant.normalized.json` 或 `plant.normalized.xml`。
+- 保留源文件;清楚列出新文件、源 SHA-256、输出 SHA-256 和重新校验结果。
+- 如果没有文本差异,说明文件无需规范化,不制造副本冒充修复结果。
+- 如果写入失败或输出校验失败,不能把不完整文件当作成功结果交付。
diff --git a/skills/system-simulation/references/workflows.md b/skills/system-simulation/references/workflows.md
new file mode 100644
index 0000000..66feb6b
--- /dev/null
+++ b/skills/system-simulation/references/workflows.md
@@ -0,0 +1,142 @@
+# 命令与对话工作流
+
+## CLI 合同
+
+从仓库根目录调用:
+
+```powershell
+py -3.12 skills/system-simulation/scripts/simulation_skill.py [--base-url URL] [--timeout SECONDS] COMMAND
+```
+
+若仓库已有 `.venv-win\Scripts\python.exe`,Windows 应优先用它替换 `py -3.12`;Linux 使用 `.venv/bin/python` 或 `python3.12`。不要使用本机可能指向旧版本的裸 `python`。默认服务地址为 `http://127.0.0.1:8000`,超时参数不得低于 10 秒,以保持在后端 5 秒心跳间隔之上。`inspect`、`repair-format`、`status` 和 `cancel` 在标准输出返回一个结构化 JSON;`simulate` 在标准输出给出节流后的 JSONL 进展和精简完成摘要。输出目录的 `progress.jsonl` 保留完整进展/错误事件,但只保存精简结果摘要;完整数值结果另存为 `result.json`,避免时间序列重复占用空间和智能体上下文。
+
+退出码:
+
+| 退出码 | 含义 |
+| --- | --- |
+| `0` | 命令按合同成功完成 |
+| `2` | 输入、参数或安全前置条件错误 |
+| `3` | HTTP、连接或后端结构化错误 |
+| `4` | 仿真事件流报告失败 |
+| `5` | 本地结果文件写入失败 |
+
+不要只看退出码 `0` 就声称仿真数值成功;还要检查最终事件和 `result.json` 中的状态。不要通过匹配本地化消息文本判断状态。
+
+## 检查与解释
+
+```powershell
+py -3.12 skills/system-simulation/scripts/simulation_skill.py inspect INPUT --format auto
+```
+
+`--format` 可为 `auto`、`json` 或 `xml`。完成后按 [file-contracts.md](file-contracts.md) 解释模型。错误和警告应保留层级、稳定错误码、路径或行号;不要只复述最后一句消息。
+
+默认只返回首批 50 个紧凑组件、25 条连接和 20 个结果变量,避免大型工程输出撑满上下文。翻阅模型摘要、按组件查看完整合同或搜索结果变量时使用:
+
+```powershell
+py -3.12 skills/system-simulation/scripts/simulation_skill.py inspect INPUT `
+ --component COMPONENT_ID `
+ --component-offset 0 `
+ --component-limit 50 `
+ --connection-offset 0 `
+ --connection-limit 25 `
+ --variable-query QUERY `
+ --variable-offset 0 `
+ --variable-limit 20
+```
+
+分别依据 `componentPage`、`connectionPage` 和 `resultVariablePage` 的 `hasMore`、`nextOffset` 继续分页,不要为寻找一个组件或变量请求全部详细合同。`componentTypes` 始终汇总完整模型,可先用它判断系统构成。`--component` 返回该 ID 的源文件数据和(若参与求解)编译合同,因此物性介质等配置节点也能查看参数。
+
+## 文本规范化修复
+
+先检查并取得源文件 SHA-256。第一次不带 `--confirmed` 调用只返回差异预览、不会写文件:
+
+```powershell
+py -3.12 skills/system-simulation/scripts/simulation_skill.py repair-format INPUT `
+ --format auto `
+ --output OUTPUT `
+ --expected-sha256 SHA256
+```
+
+向用户展示预览中的目标路径、源/输出哈希和 `confirmationToken`,取得明确确认后,再用原样 token 执行写入:
+
+```powershell
+py -3.12 skills/system-simulation/scripts/simulation_skill.py repair-format INPUT `
+ --format auto `
+ --output OUTPUT `
+ --expected-sha256 SHA256 `
+ --confirmation-token PREVIEW_TOKEN `
+ --confirmed
+```
+
+token 同时绑定源文件哈希、规范化输出哈希和目标绝对路径;任何一项变化都必须重新预览和确认。脚本拒绝覆盖源文件、哈希/token 不匹配和未确认写入。完整边界见 [repair-policy.md](repair-policy.md)。写入后再次运行 `inspect OUTPUT`。
+
+## 仿真前对话
+
+运行前必须完成以下判断:
+
+1. `inspect` 通过,并取得可用结果变量清单。
+2. 用户选择 `separate`、`overlay` 或 `stacked`。
+3. 把自然语言对象解析为稳定结果 `key`;重名、缺单位或把输入参数误称为曲线时先澄清。
+4. 向用户复述将运行的文件、仿真时段、算法、所选结果变量和曲线方式。
+
+本版没有网页自动预装能力。用户要求“网页查看”时,说明当前只能直接交付 SVG 曲线与 CSV;不要启动浏览器、生成临时 URL,或声称现有页面会自动载入文件。
+
+## 启动并监视仿真
+
+```powershell
+py -3.12 skills/system-simulation/scripts/simulation_skill.py simulate INPUT `
+ --format auto `
+ --output-dir OUTPUT_DIR `
+ --variables RESULT_KEY_1 RESULT_KEY_2 `
+ --chart-mode overlay `
+ --simulation-id SIMULATION_ID
+```
+
+`--variables` 接受结果变量稳定 `key`;不能传组件参数名。`--simulation-id` 可省略并由脚本生成,但应保存最终 ID,供恢复查询或取消使用。
+
+监视规则:
+
+- 消费 JSONL,记录最新 `progress`、`phase`、`simulatedTime/totalTime`、心跳和内部活动快照;
+- 长任务期间定期向用户给出简短进展,避免逐条转发事件;
+- 仅有仿真时间平台期不能证明卡死。活动序号、RHS、solver step、Jacobian 或闭合计数仍增长时,应报告“正在处理慢步”;
+- 网络读取中断后,用已知 simulation ID 查询一次任务快照,再决定是否继续说明、恢复结果或报告连接问题;
+- 不因运行缓慢自动取消。只有用户明确要求取消,或既有系统已经把任务判定为 stalled 时,才使用对应取消原因;
+- `completed` 才表示完整完成;`stopped`、`stalled`、`failed` 都必须标明是非完整结果。
+
+当前任务状态保存在后端进程内,终态记录只短期保留,服务重启后也不能恢复。本 Skill 不承诺跨进程或长期断线续传;需要查询时应及时保存 simulation ID、事件日志和已经写出的结果文件。
+
+`status` 对已完成任务只输出结果摘要,不在终端重复打印整套时间序列;正常 `simulate` 流程会把完整数据保存为 `result.json` 和 `results.csv`。
+
+恢复查询:
+
+```powershell
+py -3.12 skills/system-simulation/scripts/simulation_skill.py status SIMULATION_ID
+```
+
+用户要求取消时:
+
+```powershell
+py -3.12 skills/system-simulation/scripts/simulation_skill.py cancel SIMULATION_ID --reason user
+```
+
+`--reason stalled` 只用于已有充分停滞证据的内部流程,不用来表达普通的用户取消。
+
+## 结果文件与交付
+
+正常运行目录应包含:
+
+- `progress.jsonl`:原始进度、心跳和结束事件;
+- `result.json`:最终结构化结果;
+- `results.csv`:全部可用结果变量的 UTF-8 CSV;
+- 根据 `separate`、`overlay` 或 `stacked` 生成的 SVG 曲线。
+
+交付时:
+
+1. 说明最终状态和实际计算到的仿真时间;
+2. 返回用户选择的 SVG 曲线;
+3. 无论用户只选了几条曲线,都同时返回完整 `results.csv`;
+4. 若失败或取消但存在部分序列,明确标注曲线和 CSV 是部分结果;
+5. 若没有产生可用时间序列,明确说明没有 CSV,不能创建空文件冒充结果;
+6. 保留 `result.json` 和 `progress.jsonl` 作为诊断依据,但通常无需把完整事件日志逐行展示给用户。
+
+本版不会根据结果自动改变模型并重试。诊断后若要改参数、拓扑或算法,先把建议交给用户,等待后续迭代能力或单独授权的人工修改流程。
diff --git a/skills/system-simulation/scripts/simulation_skill.py b/skills/system-simulation/scripts/simulation_skill.py
new file mode 100644
index 0000000..6f99a6a
--- /dev/null
+++ b/skills/system-simulation/scripts/simulation_skill.py
@@ -0,0 +1,1933 @@
+#!/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, 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,
+) -> 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
+ 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)
+ 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,
+ ):
+ emit_json(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":
+ emit_json(logged_event)
+ else:
+ emit_json(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'')
+ 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())
diff --git a/tests/test_system_simulation_skill.py b/tests/test_system_simulation_skill.py
new file mode 100644
index 0000000..36b475e
--- /dev/null
+++ b/tests/test_system_simulation_skill.py
@@ -0,0 +1,1068 @@
+"""Standard-library tests for the system-simulation skill helper.
+
+These tests deliberately mock only the HTTP transport boundary. Parsing,
+normalization, simulation event monitoring, artifact creation, and command
+validation therefore run through the same code used by the skill.
+"""
+
+from __future__ import annotations
+
+import argparse
+import importlib.util
+import io
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
+SCRIPT_PATH = (
+ REPOSITORY_ROOT
+ / "skills"
+ / "system-simulation"
+ / "scripts"
+ / "simulation_skill.py"
+)
+MODULE_NAME = "system_simulation_skill_under_test"
+MODULE_SPEC = importlib.util.spec_from_file_location(MODULE_NAME, SCRIPT_PATH)
+if MODULE_SPEC is None or MODULE_SPEC.loader is None: # pragma: no cover
+ raise RuntimeError(f"Cannot load skill helper from {SCRIPT_PATH}")
+skill = importlib.util.module_from_spec(MODULE_SPEC)
+sys.modules[MODULE_NAME] = skill
+MODULE_SPEC.loader.exec_module(skill)
+
+
+BASE_URL = "http://127.0.0.1:8000"
+RESULT_VARIABLES = [
+ {
+ "key": "mass.position",
+ "componentId": "mass",
+ "name": "position",
+ "label": "Position",
+ "quantity": "displacement",
+ "unit": "m",
+ },
+ {
+ "key": "mass.velocity",
+ "componentId": "mass",
+ "name": "velocity",
+ "label": "Velocity",
+ "quantity": "velocity",
+ "unit": "m/s",
+ },
+]
+COMPILED_MODEL = {
+ "validation": {"valid": True, "issues": []},
+ "components": [
+ {
+ "id": "mass",
+ "type": "MECMAS21",
+ "resultVariables": RESULT_VARIABLES,
+ }
+ ],
+ "connections": [{"id": "connection-1"}],
+ "unconnectedPorts": [],
+}
+PROJECT = {
+ "projectSchemaVersion": 1,
+ "name": "json-system",
+ "nodes": [
+ {"id": "mass", "type": "component", "data": {"componentType": "MECMAS21"}}
+ ],
+ "edges": [{"id": "connection-1", "source": "mass", "target": "mass"}],
+ "simulation": {"startTime": 0.0, "endTime": 1.0},
+}
+SYSTEM_XML = b"""
+
+
+
+"""
+
+
+def json_bytes(value: object) -> bytes:
+ return json.dumps(value, ensure_ascii=False).encode("utf-8")
+
+
+def compile_http_response(
+ _base_url: str,
+ path: str,
+ **_kwargs: object,
+) -> tuple[bytes, dict[str, str]]:
+ if path == "/api/reactflow/compile-model":
+ return json_bytes(COMPILED_MODEL), {}
+ raise AssertionError(f"Unexpected HTTP request: {path}")
+
+
+class FileRecognitionTests(unittest.TestCase):
+ def test_detects_content_format_independently_of_extension(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / "project.xml"
+ path.write_text(json.dumps(PROJECT), encoding="utf-8")
+
+ source = skill.load_source(str(path), "auto")
+
+ self.assertEqual(source.format, "json")
+ self.assertEqual(source.parsed, PROJECT)
+ with self.assertRaises(skill.InputError) as caught:
+ skill.load_source(str(path), "xml")
+ self.assertEqual(caught.exception.code, "INPUT_FORMAT_MISMATCH")
+
+ def test_rejects_duplicate_json_keys(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / "duplicate.json"
+ path.write_text('{"name":"first","name":"second"}', encoding="utf-8")
+
+ with self.assertRaises(skill.InputError) as caught:
+ skill.load_source(str(path))
+
+ self.assertEqual(caught.exception.code, "JSON_SYNTAX_INVALID")
+ self.assertIn("duplicate", caught.exception.message.lower())
+
+ def test_rejects_xml_doctype_before_parsing(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / "unsafe.xml"
+ path.write_bytes(
+ b']>'
+ b"&xxe;"
+ )
+
+ with self.assertRaises(skill.InputError) as caught:
+ skill.load_source(str(path))
+
+ self.assertEqual(
+ caught.exception.code,
+ "XML_EXTERNAL_DECLARATION_FORBIDDEN",
+ )
+
+ def test_rejects_non_loopback_backend_url(self) -> None:
+ with self.assertRaises(skill.InputError) as caught:
+ skill.validate_base_url("https://simulation.example.com")
+
+ self.assertEqual(caught.exception.code, "REMOTE_BACKEND_FORBIDDEN")
+ self.assertEqual(skill.validate_base_url("http://localhost:8000/"), "http://localhost:8000")
+
+ def test_timeout_must_remain_above_backend_heartbeat(self) -> None:
+ with self.assertRaises(argparse.ArgumentTypeError):
+ skill.positive_timeout("5")
+ self.assertEqual(skill.positive_timeout("10"), 10.0)
+
+ def test_inspect_parser_exposes_bounded_paging_defaults(self) -> None:
+ args = skill.build_parser().parse_args(["inspect", "model.xml"])
+
+ self.assertEqual(args.variable_limit, 20)
+ self.assertEqual(args.component_limit, 50)
+ self.assertEqual(args.connection_limit, 25)
+ with mock.patch("sys.stderr", new=io.StringIO()), self.assertRaises(SystemExit):
+ skill.build_parser().parse_args(
+ ["inspect", "model.xml", "--component-limit", "201"]
+ )
+
+ def test_json_output_falls_back_to_utf8_on_legacy_console(self) -> None:
+ class LegacyConsole:
+ def __init__(self) -> None:
+ self.buffer = io.BytesIO()
+
+ def write(self, value: str) -> None:
+ raise UnicodeEncodeError("gbk", value, 0, 1, "unsupported")
+
+ def flush(self) -> None:
+ return None
+
+ console = LegacyConsole()
+ skill.emit_json({"unit": "kg/m³"}, stream=console)
+ self.assertEqual(
+ json.loads(console.buffer.getvalue().decode("utf-8")),
+ {"unit": "kg/m³"},
+ )
+
+
+class InspectionTests(unittest.TestCase):
+ def test_inspect_json_returns_system_summary(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / "project.json"
+ path.write_text(json.dumps(PROJECT), encoding="utf-8")
+ source = skill.load_source(str(path))
+
+ with mock.patch.object(
+ skill,
+ "http_request",
+ side_effect=compile_http_response,
+ ):
+ report = skill.inspect_source(source, base_url=BASE_URL, timeout=1.0)
+
+ self.assertTrue(report["ok"])
+ self.assertEqual(report["source"]["format"], "json")
+ self.assertEqual(report["system"]["name"], "json-system")
+ self.assertEqual(report["system"]["formatVersion"], 1)
+ self.assertEqual(report["system"]["componentCount"], 1)
+ self.assertEqual(report["system"]["connectionCount"], 1)
+ self.assertEqual(
+ [variable["key"] for variable in report["system"]["resultVariables"]],
+ ["mass.position", "mass.velocity"],
+ )
+
+ def test_inspect_xml_returns_backend_and_document_summary(self) -> None:
+ parsed_model = {
+ "name": "xml-system",
+ "components": [{"id": "mass"}, {"id": "fixed"}],
+ "connections": [{"id": "connection-1"}],
+ "simulation": {"startTime": 0.0, "endTime": 1.0},
+ }
+
+ def xml_http_response(
+ _base_url: str,
+ path: str,
+ **_kwargs: object,
+ ) -> tuple[bytes, dict[str, str]]:
+ if path == "/api/system-xml/parse":
+ return json_bytes({"model": parsed_model}), {}
+ if path == "/api/system-xml/compile-model":
+ return json_bytes(COMPILED_MODEL), {}
+ raise AssertionError(f"Unexpected HTTP request: {path}")
+
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / "system.xml"
+ path.write_bytes(SYSTEM_XML)
+ source = skill.load_source(str(path))
+
+ with mock.patch.object(
+ skill,
+ "http_request",
+ side_effect=xml_http_response,
+ ) as request:
+ report = skill.inspect_source(source, base_url=BASE_URL, timeout=1.0)
+
+ self.assertEqual(request.call_count, 2)
+ self.assertEqual(report["source"]["format"], "xml")
+ self.assertEqual(report["system"]["name"], "xml-system")
+ self.assertEqual(report["system"]["formatVersion"], "3")
+ self.assertEqual(report["system"]["componentCount"], 2)
+ self.assertEqual(report["system"]["connectionCount"], 1)
+
+ def test_inspect_pages_result_variables_and_keeps_components_compact(self) -> None:
+ variables = [
+ {
+ "key": f"mass.value_{index}",
+ "componentId": "mass",
+ "label": f"Value {index}",
+ "quantity": "dimensionless",
+ "unit": "",
+ }
+ for index in range(75)
+ ]
+ compiled = {
+ **COMPILED_MODEL,
+ "components": [
+ {
+ "id": "mass",
+ "type": "MECMAS21",
+ "parameters": [{"name": "mass", "value": 1.0}],
+ "ports": [{"name": "port_1"}],
+ "resultVariables": variables,
+ }
+ ],
+ }
+
+ def response(_base_url: str, path: str, **_kwargs: object):
+ self.assertEqual(path, "/api/reactflow/compile-model")
+ return json_bytes(compiled), {}
+
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / "project.json"
+ path.write_text(json.dumps(PROJECT), encoding="utf-8")
+ source = skill.load_source(str(path))
+ with mock.patch.object(skill, "http_request", side_effect=response):
+ report = skill.inspect_source(source, base_url=BASE_URL, timeout=10.0)
+
+ self.assertEqual(len(report["system"]["resultVariables"]), 20)
+ self.assertTrue(report["system"]["resultVariablePage"]["hasMore"])
+ self.assertEqual(report["system"]["resultVariablePage"]["nextOffset"], 20)
+ self.assertEqual(report["system"]["componentPage"]["total"], 1)
+ self.assertEqual(report["system"]["connectionPage"]["total"], 1)
+ self.assertNotIn("parameters", report["system"]["components"][0])
+
+ def test_inspect_keeps_source_only_medium_component_visible(self) -> None:
+ project = {
+ **PROJECT,
+ "nodes": [
+ {
+ "id": "medium",
+ "type": "simulationComponent",
+ "data": {
+ "label": "Helium properties",
+ "componentType": "amesim_helium_medium",
+ "modelVersion": "0.1.0",
+ "parameters": {"property_model": 0},
+ "ports": [],
+ },
+ },
+ *PROJECT["nodes"],
+ ],
+ }
+
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / "project.json"
+ path.write_text(json.dumps(project), encoding="utf-8")
+ source = skill.load_source(str(path))
+ with mock.patch.object(
+ skill,
+ "http_request",
+ side_effect=compile_http_response,
+ ):
+ report = skill.inspect_source(
+ source,
+ base_url=BASE_URL,
+ timeout=10.0,
+ component_ids=("medium",),
+ )
+
+ self.assertEqual(report["system"]["componentCount"], 2)
+ self.assertEqual(report["system"]["componentPage"]["total"], 2)
+ self.assertEqual(report["system"]["components"][0]["id"], "medium")
+ self.assertEqual(report["system"]["components"][0]["label"], "Helium properties")
+ self.assertEqual(report["system"]["components"][0]["modelVersion"], "0.1.0")
+ self.assertFalse(report["system"]["components"][0]["compiledForSimulation"])
+ self.assertEqual(report["system"]["missingComponentDetails"], [])
+ medium_detail = report["system"]["componentDetails"][0]
+ self.assertFalse(medium_detail["compiledForSimulation"])
+ self.assertEqual(medium_detail["source"]["data"]["parameters"], {"property_model": 0})
+ self.assertIsNone(medium_detail["compiled"])
+
+ def test_variable_query_is_applied_before_nonzero_final_page(self) -> None:
+ matching = [
+ {
+ "key": f"mass.speed_{index}",
+ "componentId": "mass",
+ "label": f"Speed {index}",
+ "quantity": "velocity",
+ "unit": "m/s",
+ }
+ for index in range(5)
+ ]
+ unrelated = [
+ {
+ "key": f"mass.position_{index}",
+ "componentId": "mass",
+ "label": f"Position {index}",
+ "quantity": "displacement",
+ "unit": "m",
+ }
+ for index in range(8)
+ ]
+ compiled = {
+ **COMPILED_MODEL,
+ "components": [
+ {
+ "id": "mass",
+ "type": "MECMAS21",
+ "resultVariables": [*unrelated, *matching],
+ }
+ ],
+ }
+
+ def response(_base_url: str, path: str, **_kwargs: object):
+ self.assertEqual(path, "/api/reactflow/compile-model")
+ return json_bytes(compiled), {}
+
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / "project.json"
+ path.write_text(json.dumps(PROJECT), encoding="utf-8")
+ source = skill.load_source(str(path))
+ with mock.patch.object(skill, "http_request", side_effect=response):
+ report = skill.inspect_source(
+ source,
+ base_url=BASE_URL,
+ timeout=10.0,
+ variable_query="speed",
+ variable_offset=3,
+ variable_limit=2,
+ )
+
+ page = report["system"]["resultVariablePage"]
+ self.assertEqual(
+ [variable["key"] for variable in report["system"]["resultVariables"]],
+ ["mass.speed_3", "mass.speed_4"],
+ )
+ self.assertEqual(page["query"], "speed")
+ self.assertEqual(page["offset"], 3)
+ self.assertEqual(page["total"], 5)
+ self.assertFalse(page["hasMore"])
+ self.assertIsNone(page["nextOffset"])
+
+ def test_xml_source_only_medium_survives_on_final_component_page(self) -> None:
+ parsed_model = {
+ "name": "xml-medium-system",
+ "components": [
+ {"id": "mass", "model_type": "amesim_mecmas21"},
+ {"id": "fixed", "model_type": "amesim_fixed"},
+ {
+ "id": "medium",
+ "model_type": "amesim_helium_medium",
+ "parameters": {"property_model": 0},
+ },
+ ],
+ "connections": [],
+ "simulation": {"t_start": 0.0, "t_stop": 1.0},
+ }
+ compiled = {
+ **COMPILED_MODEL,
+ "components": [
+ {"id": "mass", "type": "amesim_mecmas21", "resultVariables": []},
+ {"id": "fixed", "type": "amesim_fixed", "resultVariables": []},
+ ],
+ "connections": [],
+ }
+
+ def response(
+ _base_url: str,
+ path: str,
+ **_kwargs: object,
+ ) -> tuple[bytes, dict[str, str]]:
+ if path == "/api/system-xml/parse":
+ return json_bytes({"model": parsed_model}), {}
+ if path == "/api/system-xml/compile-model":
+ return json_bytes(compiled), {}
+ raise AssertionError(f"Unexpected HTTP request: {path}")
+
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / "system.xml"
+ path.write_bytes(SYSTEM_XML)
+ source = skill.load_source(str(path))
+ with mock.patch.object(skill, "http_request", side_effect=response):
+ report = skill.inspect_source(
+ source,
+ base_url=BASE_URL,
+ timeout=10.0,
+ component_offset=2,
+ component_limit=1,
+ )
+
+ system = report["system"]
+ self.assertEqual(system["componentPage"]["total"], 3)
+ self.assertEqual(system["componentPage"]["offset"], 2)
+ self.assertFalse(system["componentPage"]["hasMore"])
+ self.assertIsNone(system["componentPage"]["nextOffset"])
+ self.assertEqual(len(system["components"]), 1)
+ self.assertEqual(system["components"][0]["id"], "medium")
+ self.assertEqual(system["components"][0]["type"], "amesim_helium_medium")
+ self.assertFalse(system["components"][0]["compiledForSimulation"])
+ self.assertIn(
+ {"type": "amesim_helium_medium", "count": 1},
+ system["componentTypes"],
+ )
+
+
+class TextNormalizationTests(unittest.TestCase):
+ def _repair_args(
+ self,
+ source: Path,
+ output: Path,
+ expected_hash: str,
+ *,
+ confirmed: bool,
+ confirmation_token: str | None = None,
+ ) -> argparse.Namespace:
+ return argparse.Namespace(
+ input=str(source),
+ format="auto",
+ output=str(output),
+ expected_sha256=expected_hash,
+ confirmed=confirmed,
+ confirmation_token=confirmation_token,
+ base_url=BASE_URL,
+ timeout=1.0,
+ )
+
+ def test_preview_does_not_write_and_confirmation_writes_new_file(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ source_path = root / "source.json"
+ output_path = root / "normalized.json"
+ raw = b"\xef\xbb\xbf" + json.dumps(PROJECT, separators=(",", ":")).encode("utf-8") + b"\r\n"
+ source_path.write_bytes(raw)
+ source_hash = skill.load_source(str(source_path)).sha256
+
+ with (
+ mock.patch.object(
+ skill,
+ "http_request",
+ side_effect=compile_http_response,
+ ),
+ mock.patch.object(skill, "emit_json") as emit,
+ ):
+ preview_code = skill.command_repair_format(
+ self._repair_args(
+ source_path,
+ output_path,
+ source_hash,
+ confirmed=False,
+ )
+ )
+ preview = emit.call_args.args[0]
+
+ self.assertEqual(preview_code, 0)
+ self.assertFalse(output_path.exists())
+ self.assertFalse(preview["written"])
+ self.assertTrue(preview["requiresConfirmation"])
+ self.assertEqual(preview["preview"]["sourceSha256"], source_hash)
+ self.assertTrue(preview["preview"]["unifiedDiff"])
+
+ emit.reset_mock()
+ write_code = skill.command_repair_format(
+ self._repair_args(
+ source_path,
+ output_path,
+ source_hash,
+ confirmed=True,
+ confirmation_token=preview["preview"]["confirmationToken"],
+ )
+ )
+ written = emit.call_args.args[0]
+
+ normalized = output_path.read_bytes()
+ self.assertEqual(write_code, 0)
+ self.assertTrue(written["written"])
+ self.assertFalse(normalized.startswith(b"\xef\xbb\xbf"))
+ self.assertNotIn(b"\r\n", normalized)
+ self.assertTrue(normalized.endswith(b"\n"))
+ self.assertEqual(json.loads(normalized), PROJECT)
+ self.assertEqual(source_path.read_bytes(), raw)
+
+ def test_stale_confirmation_hash_and_source_overwrite_are_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ source_path = root / "source.json"
+ source_path.write_text(json.dumps(PROJECT), encoding="utf-8")
+ source_hash = skill.load_source(str(source_path)).sha256
+
+ with self.assertRaises(skill.InputError) as stale:
+ skill.command_repair_format(
+ self._repair_args(
+ source_path,
+ root / "output.json",
+ "0" * 64,
+ confirmed=True,
+ )
+ )
+ self.assertEqual(stale.exception.code, "SOURCE_HASH_MISMATCH")
+
+ with self.assertRaises(skill.InputError) as overwrite:
+ skill.command_repair_format(
+ self._repair_args(
+ source_path,
+ source_path,
+ source_hash,
+ confirmed=True,
+ )
+ )
+ self.assertEqual(overwrite.exception.code, "SOURCE_OVERWRITE_FORBIDDEN")
+
+ def test_confirmation_token_binds_the_preview_target(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ source_path = root / "source.json"
+ preview_target = root / "preview-target.json"
+ changed_target = root / "changed-target.json"
+ source_path.write_text(json.dumps(PROJECT, separators=(",", ":")), encoding="utf-8")
+ source_hash = skill.load_source(str(source_path)).sha256
+
+ with (
+ mock.patch.object(
+ skill,
+ "http_request",
+ side_effect=compile_http_response,
+ ),
+ mock.patch.object(skill, "emit_json") as emit,
+ ):
+ skill.command_repair_format(
+ self._repair_args(
+ source_path,
+ preview_target,
+ source_hash,
+ confirmed=False,
+ )
+ )
+ token = emit.call_args.args[0]["preview"]["confirmationToken"]
+
+ with self.assertRaises(skill.InputError) as caught:
+ skill.command_repair_format(
+ self._repair_args(
+ source_path,
+ changed_target,
+ source_hash,
+ confirmed=True,
+ confirmation_token=token,
+ )
+ )
+
+ self.assertEqual(caught.exception.code, "REPAIR_CONFIRMATION_TOKEN_MISMATCH")
+ self.assertFalse(preview_target.exists())
+ self.assertFalse(changed_target.exists())
+
+ def test_output_race_never_deletes_a_file_created_by_another_process(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ output = Path(directory) / "race.json"
+
+ def raced_open(*_args: object, **_kwargs: object):
+ with io.open(output, "wb") as handle:
+ handle.write(b"other-process")
+ raise FileExistsError("simulated output race")
+
+ with mock.patch("pathlib.Path.open", side_effect=raced_open):
+ with self.assertRaises(skill.ArtifactError):
+ skill._write_new_file(output, b"skill-output")
+
+ self.assertEqual(output.read_bytes(), b"other-process")
+
+
+class SimulationTests(unittest.TestCase):
+ def _simulate_args(
+ self,
+ source: Path,
+ output: Path,
+ *,
+ variables: list[str],
+ chart_mode: str,
+ ) -> argparse.Namespace:
+ return argparse.Namespace(
+ input=str(source),
+ format="auto",
+ output_dir=str(output),
+ variables=variables,
+ chart_mode=chart_mode,
+ simulation_id="simulation-test-1",
+ base_url=BASE_URL,
+ timeout=1.0,
+ )
+
+ def test_streaming_simulation_writes_result_full_csv_and_svg(self) -> None:
+ result = {
+ "status": "completed",
+ "success": True,
+ "actualEndTime": 1.0,
+ "variables": RESULT_VARIABLES,
+ "series": {
+ "time": [0.0, 0.5, 1.0],
+ "mass.position": [0.0, 0.25, 1.0],
+ "mass.velocity": [0.0, 1.0, 2.0],
+ },
+ }
+ stream = io.BytesIO(
+ b'{"event":"progress","phase":"integrating","progress":50}\n'
+ + json_bytes({"event": "result", "result": result})
+ + b"\n"
+ )
+ csv_payloads: list[dict[str, object]] = []
+
+ def simulation_http_response(
+ _base_url: str,
+ path: str,
+ **kwargs: object,
+ ) -> tuple[bytes, dict[str, str]]:
+ if path == "/api/reactflow/compile-model":
+ return json_bytes(COMPILED_MODEL), {}
+ if path == "/api/reactflow/system-xml":
+ return SYSTEM_XML, {"Content-Type": "application/xml"}
+ if path == "/api/simulation-results/csv":
+ body = kwargs.get("body")
+ if not isinstance(body, bytes):
+ raise AssertionError("CSV request did not contain a JSON body")
+ csv_payloads.append(json.loads(body.decode("utf-8")))
+ return b"time,mass.position,mass.velocity\n0,0,0\n0.5,0.25,1\n1,1,2\n", {}
+ raise AssertionError(f"Unexpected HTTP request: {path}")
+
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ source_path = root / "project.json"
+ output_path = root / "artifacts"
+ source_path.write_text(json.dumps(PROJECT), encoding="utf-8")
+
+ with (
+ mock.patch.object(
+ skill,
+ "http_request",
+ side_effect=simulation_http_response,
+ ),
+ mock.patch.object(
+ skill,
+ "_open_simulation_stream",
+ return_value=stream,
+ ) as open_stream,
+ mock.patch.object(skill, "emit_json") as emit,
+ ):
+ exit_code = skill.command_simulate(
+ self._simulate_args(
+ source_path,
+ output_path,
+ variables=["mass.position"],
+ chart_mode="separate",
+ )
+ )
+
+ self.assertEqual(exit_code, 0)
+ open_stream.assert_called_once_with(
+ BASE_URL,
+ SYSTEM_XML,
+ "simulation-test-1",
+ 1.0,
+ )
+ self.assertEqual(json.loads((output_path / "result.json").read_text("utf-8")), result)
+ self.assertIn("mass.velocity", (output_path / "results.csv").read_text("utf-8"))
+ charts = list(output_path.glob("curve-mass.position-*.svg"))
+ self.assertEqual(len(charts), 1)
+ chart = charts[0]
+ self.assertIn(" None:
+ event = {"event": "progress", "phase": "integrating", "progress": 4}
+
+ self.assertFalse(
+ skill._should_emit_progress(
+ event,
+ previous_phase="integrating",
+ previous_progress=0.0,
+ seconds_since_emit=1.0,
+ )
+ )
+ self.assertTrue(
+ skill._should_emit_progress(
+ {**event, "progress": 5},
+ previous_phase="integrating",
+ previous_progress=0.0,
+ seconds_since_emit=1.0,
+ )
+ )
+ self.assertTrue(
+ skill._should_emit_progress(
+ event,
+ previous_phase="compilation",
+ previous_progress=0.0,
+ seconds_since_emit=1.0,
+ )
+ )
+ self.assertTrue(
+ skill._should_emit_progress(
+ event,
+ previous_phase="integrating",
+ previous_progress=0.0,
+ seconds_since_emit=5.0,
+ )
+ )
+
+ def test_real_stream_throttles_stdout_but_logs_every_progress_event(self) -> None:
+ progress_events = [
+ {"event": "progress", "phase": "validation", "progress": 0},
+ {"event": "progress", "phase": "validation", "progress": 1},
+ {"event": "progress", "phase": "validation", "progress": 4},
+ {"event": "progress", "phase": "validation", "progress": 5},
+ {"event": "progress", "phase": "integrating", "progress": 6},
+ {"event": "progress", "phase": "integrating", "progress": 7},
+ {"event": "progress", "phase": "integrating", "progress": 100},
+ ]
+ result = {
+ "status": "completed",
+ "success": True,
+ "actualEndTime": 1.0,
+ "variables": RESULT_VARIABLES,
+ "series": {
+ "time": [0.0, 1.0],
+ "mass.position": [0.0, 1.0],
+ "mass.velocity": [0.0, 2.0],
+ },
+ }
+ lines = [json_bytes(event) + b"\n" for event in progress_events]
+ lines.append(json_bytes({"event": "result", "result": result}) + b"\n")
+
+ with tempfile.TemporaryDirectory() as directory:
+ progress_path = Path(directory) / "progress.jsonl"
+ with (
+ mock.patch.object(
+ skill,
+ "_open_simulation_stream",
+ return_value=io.BytesIO(b"".join(lines)),
+ ),
+ mock.patch.object(
+ skill.time,
+ "monotonic",
+ side_effect=[100.0, 101.0, 102.0, 103.0, 104.0, 110.0, 111.0],
+ ),
+ mock.patch.object(skill, "emit_json") as emit,
+ ):
+ returned_result, returned_error = skill._read_simulation_stream(
+ BASE_URL,
+ SYSTEM_XML,
+ "simulation-test-1",
+ 10.0,
+ progress_path,
+ )
+
+ logged = [
+ json.loads(line)
+ for line in progress_path.read_text("utf-8").splitlines()
+ ]
+
+ self.assertEqual(returned_result, result)
+ self.assertIsNone(returned_error)
+ self.assertEqual(logged[:7], progress_events)
+ self.assertEqual(len(logged), 8)
+ self.assertEqual(logged[-1]["event"], "result")
+ self.assertNotIn("result", logged[-1])
+ public = [call.args[0] for call in emit.call_args_list]
+ self.assertEqual(
+ [event.get("progress") for event in public if event["event"] == "progress"],
+ [0, 5, 6, 7, 100],
+ )
+ self.assertEqual(public[-1]["event"], "result")
+ self.assertNotIn("result", public[-1])
+ self.assertEqual(public[-1]["sampleCount"], 2)
+
+ def test_overlay_and_stacked_positive_chart_generation(self) -> None:
+ variables = {
+ "mass.position": RESULT_VARIABLES[0],
+ "reference.position": {
+ **RESULT_VARIABLES[0],
+ "key": "reference.position",
+ "componentId": "reference",
+ },
+ "mass.velocity": RESULT_VARIABLES[1],
+ }
+ series = {
+ "time": [0.0, 0.5, 1.0],
+ "mass.position": [0.0, 0.25, 1.0],
+ "reference.position": [1.0, 0.5, 0.0],
+ "mass.velocity": [0.0, 1.0, 2.0],
+ }
+
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ overlay_paths = skill._write_charts(
+ root,
+ "overlay",
+ ["mass.position", "reference.position"],
+ variables,
+ series,
+ )
+ stacked_paths = skill._write_charts(
+ root,
+ "stacked",
+ ["mass.position", "mass.velocity"],
+ variables,
+ series,
+ )
+
+ overlay = Path(overlay_paths[0]).read_text("utf-8")
+ stacked = Path(stacked_paths[0]).read_text("utf-8")
+
+ self.assertEqual([Path(path).name for path in overlay_paths], ["curves-overlay.svg"])
+ self.assertEqual([Path(path).name for path in stacked_paths], ["curves-stacked.svg"])
+ self.assertEqual(overlay.count(" None:
+ partial_result = {
+ "status": "stopped",
+ "success": False,
+ "partial": True,
+ "simulatedUntil": 0.5,
+ "variables": RESULT_VARIABLES,
+ "series": {
+ "time": [0.0, 0.5],
+ "mass.position": [0.0, 0.25],
+ "mass.velocity": [0.0, 1.0],
+ },
+ }
+ inspection = {
+ "system": {
+ "name": "partial-system",
+ "resultVariables": RESULT_VARIABLES,
+ }
+ }
+
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ source_path = root / "project.json"
+ output_path = root / "partial-artifacts"
+ source_path.write_text(json.dumps(PROJECT), encoding="utf-8")
+
+ with (
+ mock.patch.object(skill, "inspect_source", return_value=inspection),
+ mock.patch.object(skill, "_json_to_xml", return_value=SYSTEM_XML),
+ mock.patch.object(
+ skill,
+ "_read_simulation_stream",
+ return_value=(partial_result, None),
+ ),
+ mock.patch.object(
+ skill,
+ "_download_csv",
+ return_value=b"time,mass.position,mass.velocity\n0,0,0\n0.5,0.25,1\n",
+ ),
+ mock.patch.object(skill, "emit_json") as emit,
+ ):
+ exit_code = skill.command_simulate(
+ self._simulate_args(
+ source_path,
+ output_path,
+ variables=["mass.position"],
+ chart_mode="separate",
+ )
+ )
+
+ artifact_event = emit.call_args.args[0]
+ self.assertEqual(exit_code, 4)
+ self.assertEqual(artifact_event["event"], "artifacts")
+ self.assertEqual(artifact_event["status"], "stopped")
+ self.assertTrue(artifact_event["partial"])
+ self.assertTrue((output_path / "result.json").is_file())
+ self.assertTrue((output_path / "results.csv").is_file())
+ self.assertEqual(len(list(output_path.glob("curve-mass.position-*.svg"))), 1)
+
+ def test_overlay_unit_mismatch_is_rejected_before_simulation(self) -> None:
+ mismatched = {
+ "system": {
+ "name": "unit-test",
+ "resultVariables": [
+ {"key": "pressure", "unit": "Pa"},
+ {"key": "flow", "unit": "kg/s"},
+ ],
+ }
+ }
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ source_path = root / "project.json"
+ output_path = root / "must-not-exist"
+ source_path.write_text(json.dumps(PROJECT), encoding="utf-8")
+
+ with (
+ mock.patch.object(skill, "inspect_source", return_value=mismatched),
+ mock.patch.object(skill, "_read_simulation_stream") as run,
+ ):
+ with self.assertRaises(skill.InputError) as caught:
+ skill.command_simulate(
+ self._simulate_args(
+ source_path,
+ output_path,
+ variables=["pressure", "flow"],
+ chart_mode="overlay",
+ )
+ )
+
+ self.assertEqual(caught.exception.code, "OVERLAY_UNITS_MISMATCH")
+ run.assert_not_called()
+ self.assertFalse(output_path.exists())
+
+
+class TaskControlTests(unittest.TestCase):
+ def test_status_queries_the_requested_task(self) -> None:
+ args = argparse.Namespace(
+ simulation_id="run-123",
+ base_url=BASE_URL,
+ timeout=2.0,
+ )
+ task = {"simulationId": "run-123", "status": "integrating"}
+
+ with (
+ mock.patch.object(skill, "http_json", return_value=task) as http_json,
+ mock.patch.object(skill, "emit_json") as emit,
+ ):
+ exit_code = skill.command_status(args)
+
+ self.assertEqual(exit_code, 0)
+ http_json.assert_called_once_with(
+ BASE_URL,
+ "/api/system-xml/simulations/run-123",
+ timeout=2.0,
+ )
+ self.assertEqual(
+ emit.call_args.args[0],
+ {"ok": True, "command": "status", "task": task},
+ )
+
+ def test_status_compacts_a_completed_result(self) -> None:
+ args = argparse.Namespace(
+ simulation_id="run-123",
+ base_url=BASE_URL,
+ timeout=2.0,
+ )
+ task = {
+ "simulationId": "run-123",
+ "status": "completed",
+ "result": {
+ "status": "completed",
+ "success": True,
+ "simulatedUntil": 1.0,
+ "variables": RESULT_VARIABLES,
+ "series": {"time": [0.0, 1.0], "mass.position": [0.0, 1.0]},
+ },
+ }
+
+ with (
+ mock.patch.object(skill, "http_json", return_value=task),
+ mock.patch.object(skill, "emit_json") as emit,
+ ):
+ exit_code = skill.command_status(args)
+
+ self.assertEqual(exit_code, 0)
+ public_task = emit.call_args.args[0]["task"]
+ self.assertNotIn("result", public_task)
+ self.assertNotIn("fullResult", public_task["resultSummary"])
+ self.assertEqual(public_task["resultSummary"]["sampleCount"], 2)
+ self.assertEqual(public_task["resultSummary"]["actualEndTime"], 1.0)
+
+ def test_cancel_posts_reason_to_the_requested_task(self) -> None:
+ args = argparse.Namespace(
+ simulation_id="run-123",
+ reason="stalled",
+ base_url=BASE_URL,
+ timeout=2.0,
+ )
+ task = {"simulationId": "run-123", "status": "cancelling"}
+
+ with (
+ mock.patch.object(skill, "http_json", return_value=task) as http_json,
+ mock.patch.object(skill, "emit_json") as emit,
+ ):
+ exit_code = skill.command_cancel(args)
+
+ self.assertEqual(exit_code, 0)
+ http_json.assert_called_once_with(
+ BASE_URL,
+ "/api/system-xml/simulations/run-123/cancel",
+ method="POST",
+ payload={"reason": "stalled"},
+ timeout=2.0,
+ )
+ self.assertEqual(
+ emit.call_args.args[0],
+ {"ok": True, "command": "cancel", "task": task},
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()