From bf2bf48ad0459612854712d967d8f1ced399ae11 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Tue, 18 Aug 2026 14:39:46 +0800 Subject: [PATCH] feat(software): add guarded ANSYS node adapter --- DESIGN.md | 4 +- PROGRESS.md | 4 +- RUN.md | 8 +- ...ansys.mechanical.static_structural.v1.json | 244 ++++++++++++++++++ tests/test_ansys_adapter.py | 137 ++++++++++ tests/test_software_job_tools.py | 6 + tests/test_windows_node_source.py | 30 ++- tools/software_jobs.py | 3 +- windows-node/README.md | 6 +- .../Zcbot.WindowsNode/AdapterProcessRunner.cs | 5 +- .../Zcbot.WindowsNode/NodeAdapters.cs | 41 ++- .../Zcbot.WindowsNode.csproj | 18 +- .../adapter.json | 10 + .../requirements.txt | 1 + .../worker.py | 124 +++++++++ .../adapters/origin.plot@v2/adapter.json | 1 + windows-node/install-windows-node.bat | 82 ++++-- windows-node/package-windows-node.bat | 14 +- 18 files changed, 684 insertions(+), 54 deletions(-) create mode 100644 software-contracts/ansys.mechanical.static_structural.v1.json create mode 100644 tests/test_ansys_adapter.py create mode 100644 windows-node/adapters/ansys.mechanical.static_structural@v1/adapter.json create mode 100644 windows-node/adapters/ansys.mechanical.static_structural@v1/requirements.txt create mode 100644 windows-node/adapters/ansys.mechanical.static_structural@v1/worker.py diff --git a/DESIGN.md b/DESIGN.md index 64838bb..733df3a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -462,7 +462,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB) 云端控制面使用独立的 `software_node_enrollments` 与 `software_nodes`,不复用用户外部系统连接。管理员创建的一次性注册码具有 128 bit 随机熵,数据库只保存 SHA-256 摘要;节点注册在行锁事务中校验有效期、预期名称和允许能力,成功后原子消费。每个节点获得独立高熵 Token,数据库只保存 bcrypt 强哈希,明文仅在注册响应出现一次。 -专业软件采用“共享能力契约 + 本机 adapter”边界。仓库根目录 `software-contracts/*.json` 是语言无关的声明式事实源,描述 capability、请求 JSON Schema、输入额度、输出 manifest、feature 与最低 adapter 版本;Core 按文件签名热加载契约,只负责身份、账本、调度、传输、摘要与最终发布,不包含 Origin 或其他软件的操作分支。热加载先完整校验新快照再原子切换,写入中或非法版本继续使用上一份有效快照。Windows Node 是稳定的可信宿主,只负责持久化 job 目录、下载/上传、恢复、取消,以及从程序目录 `adapters/*/adapter.json` 发现 adapter;Host 对请求只做通用 JSON Schema 校验,具体软件探测和二次语义校验属于 Worker。manifest 声明 capability、实际 adapter 版本、运行类型、入口和契约文件,版本不编译进 EXE。Worker 可以是受管 Python 脚本或独立 EXE,进程只接收 job 目录,并以 `--probe` 返回运行状态,通过 `state.json`、`terminal.json` 与固定输出目录交接;Python、COM 或某个 SDK 都不属于通用协议。当前 Origin adapter 使用 Python 只是实现选择。 +专业软件采用“共享能力契约 + 本机 adapter”边界。仓库根目录 `software-contracts/*.json` 是语言无关的声明式事实源,描述 capability、请求 JSON Schema、输入额度、输出 manifest、feature 与最低 adapter 版本;Core 按文件签名热加载契约,只负责身份、账本、调度、传输、摘要与最终发布,不包含 Origin 或其他软件的操作分支。热加载先完整校验新快照再原子切换,写入中或非法版本继续使用上一份有效快照。Windows Node 是稳定的可信宿主,只负责持久化 job 目录、下载/上传、恢复、取消,以及从程序目录 `adapters/*/adapter.json` 发现 adapter;Host 对请求只做通用 JSON Schema 校验,具体软件探测和二次语义校验属于 Worker。manifest 声明 capability、实际 adapter 版本、运行类型、入口和契约文件,并可声明 1–1440 分钟的单任务超时;旧 manifest 缺省时保持 30 分钟,版本与时限不编译进 EXE。完整 Node 包按目录自动收集 adapter,并把根目录契约复制到只读 `software-contracts/`;单 adapter 更新包仍可把同一契约副本放在 adapter 目录,加载时本地副本优先,两者都不形成源码第二事实源。Worker 可以是受管 Python 脚本或独立 EXE,进程只接收 job 目录,并以 `--probe` 返回运行状态,通过 `state.json`、`terminal.json` 与固定输出目录交接;Python、COM 或某个 SDK 都不属于通用协议。当前 Origin 和 ANSYS adapter 使用 Python 只是实现选择。 扩展现有 capability 的 feature 时修改共享契约、对应 Worker 和测试,不修改 Core 调度、Job 生命周期或 Node EXE;增加新专业软件时新增契约与一个 adapter 目录。Cloud 会在下一次契约查询时获得新校验和工具 schema;Node 在启动时从本地目录获得能力、版本和入口。当前选择明确的运维边界:zcbot 契约可热更新,不要求重启;本机 adapter 更新时先退出托盘 Node,整体替换 adapter 目录后重新启动,不重装、不替换 Node EXE,也不建设自动更新平台。当前 Node 仍按整机单执行槽保守串行,未来只有真实并行软件需求出现时,才把 slot 账本升级为 per-capability 租约,而不改变 Job 协议。 @@ -486,6 +486,8 @@ Recipe 是专业软件的声明式目标状态,不是任意脚本或逐次鼠 Job 完成后的对话闭环复用 `software_jobs` 账本,不增加通用事件表。提交时用 `completion_action=report|analyze` 区分“只报告产物”和“报告并分析”,未明确分析时默认 `report`;成功终态将 `followup_status` 置为 `pending`。分发器只在原 task 空闲时领取:`report` 直接写入带 artifact refs 的固定 assistant 消息,不产生模型费用;`analyze` 写入前端隐藏的内部完成事件,并复用 task 单活锁、持久消息和 SSE run 自动续跑 Agent。`pending/running/completed/failed` 状态使服务重启、对话繁忙和重复完成回执均不会造成重复回复;Job 中心的手动深入分析通过同一入口重新排入分析,而不是模拟点击或只预填输入框。 +ANSYS 第二能力第一阶段新增 `ansys.mechanical.static_structural@v1`,目标固定为 Windows 上的 Mechanical 2024 R2(revision 242)。契约只接受一份已登记 STEP/Parasolid/IGES 几何、显式单位、内置 Structural Steel、全局网格尺寸、Named Selection 固定支撑、力/压力和三类结果,不接受 Mechanical 工程、Python、APDL、Journal、URL 或本机路径;输出命名固定为 MECHDAT、汇总/结果表、应力/变形图、solver log 和 `.meta` 溯源。adapter 使用独立 `runtimes/ansys` Python 3.12 环境与 240 分钟 Worker 超时,不与 Origin 依赖混用。2024 R2 没有新版 PyMechanical 的进程内许可证管理,周期 probe 只做 v242 安装和绑定探测,真实许可证通过首次求解验收;在专用节点完成连续算例、取消、残留进程和许可证释放检查前,由机器级验收门保持 `health=unavailable`,且 capability 不默认加入注册码。 + 后续仍需实现 Token 轮换;不得以任意命令或脚本接口临时代替。当前 Job 中心采用轮询而非用户事件推送,单活与 offer 选择仍只覆盖单 Web 进程;生产启用多实例前必须增加 Redis/PG fencing 或固定路由到单一控制面实例。 --- diff --git a/PROGRESS.md b/PROGRESS.md index 8f56a35..4af79c0 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-08-17(专业软件 Job 单一完成通知与 Origin 3D 画布修复,未发版) +最后更新:2026-08-18(ANSYS Mechanical 2024 R2 Node 能力第一阶段,未发版) --- @@ -20,6 +20,8 @@ --- ## 已完成关键能力 +- **08-18 / Unreleased / ANSYS Mechanical 2024 R2 Node 第一阶段**:新增非默认注册的 `ansys.mechanical.static_structural@v1`,以单几何、显式单位、Structural Steel、Named Selection 支撑/力/压力和受控结果建立声明式静力契约,固定 Worker 已完成 v242/PyMechanical probe、二次语义校验及真机验收门,尚未开放真实求解。Windows Node 打包改为自动收集多个 adapter 和共享契约,manifest 可声明每软件 1–1440 分钟超时且兼容旧版 30 分钟默认值,统一安装器为 Origin/ANSYS 创建隔离 Python 3.12 runtime;68 项专项 unittest、Ruff 致命规则与 .NET build 通过,全量 659 项仅 3 个既有数据库集成模块因测试库缺少 `users` 表未通过(另跳过 4 项),未连接 ANSYS 或数据库。 + - **08-17 / Unreleased / Software Job 单一完成通知与 Origin PNG/3D 修复**:提交/修订工具把成功排队定义为当前 Agent run 的终点,并在返回值中声明后续由系统自动发布,避免 Agent `sleep`、查询、发布后再收到固定完成消息;Origin adapter 0.9.4 不再使用会绘制文字基线的原生 PNG 路径,改为清理 PDF 基线后按请求 DPI 栅格化,同时在添加图形对象前应用最终画布并把模板图层换算为页相对尺寸,修复 3D 曲面图缩小画布后的越界裁切。专项测试、Python 编译与 diff 检查通过,生产数据库仅做只读任务核查。 - **08-17 / Unreleased / Software Job 完成闭环与结果中心优化**:`software_job_submit` 新增默认 `report`、可选 `analyze` 的完成策略,0034 在原 Job 账本加入可恢复 follow-up 状态;成功任务可直接向原对话报告产物,或在 task 空闲后复用单活锁和 SSE 自动续跑分析,内部完成事件不冒充用户消息,手动深入/重新分析走同一幂等入口。Job 中心改为结果优先布局,显示短 Job ID、输入输出、完成方式和耗时,折叠展示完整 ID/输出目录/执行版本,并支持复制、查看结果及状态化分析按钮。相关专项 unittest、Python/JavaScript 语法、Ruff 致命规则和 diff 检查通过;未连接生产 DB、未执行 migration。 diff --git a/RUN.md b/RUN.md index d39d196..5e96e85 100644 --- a/RUN.md +++ b/RUN.md @@ -1091,14 +1091,16 @@ Zcbot.WindowsNode.exe enroll ` Zcbot.WindowsNode.exe ``` -Windows Node 发布目录包含纯 BAT 安装入口,不依赖 PowerShell。使用实际运行 Node 的专用 Windows 账号双击 `install-windows-node.bat`;安装器会检测 Origin/OriginPro,检测到后自动通过 `py -3.12` 或 PATH 定位 Python 3.12 并安装固定 runtime,未检测到则跳过,最后通过 `schtasks.exe` 注册该账号登录后的启动任务,结束后保留窗口。也可在命令提示符执行: +Windows Node 发布目录包含纯 BAT 安装入口,不依赖 PowerShell。使用实际运行 Node 的专用 Windows 账号双击 `install-windows-node.bat`;安装器会分别检测 Origin/OriginPro 与 `C:\Program Files\ANSYS Inc\v242`(或 `AWP_ROOT242`)中的 Mechanical 2024 R2,检测到后自动通过 `py -3.12` 或 PATH 定位 Python 3.12,并为两者安装相互隔离的固定 runtime;未检测到的软件会跳过。最后通过 `schtasks.exe` 注册该账号登录后的启动任务,结束后保留窗口。也可在命令提示符执行: ```bat cd /d D:\ZcbotNode install-windows-node.bat ``` -若 Python 未加入 PATH,可把绝对路径作为第一个参数,例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认解释器为 `%ProgramData%\Zcbot\WindowsNode\runtimes\origin\Scripts\python.exe`。如需使用其他受管解释器,优先设置机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON`;旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务和 runtime 集中保存在 `%ProgramData%\Zcbot\WindowsNode\`,不会因替换程序目录而丢失。运行时固定依赖见发布目录的 `adapters/origin.plot@v2/requirements.txt`;任务请求无权选择解释器、脚本或路径。当前 Worker 支持 CSV/XLSX/JSON 输入及 OPJU/PNG/SVG/PDF 输出。成功产物由 Node 流式上传,全部校验通过后发布到任务工作目录 `origin//`,plot spec 与 provenance 位于其 `.meta/`;上传中断会在重连时幂等续传。 +若 Python 未加入 PATH,可把绝对路径作为第一个参数,例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认解释器分别为 `%ProgramData%\Zcbot\WindowsNode\runtimes\origin\Scripts\python.exe` 与 `%ProgramData%\Zcbot\WindowsNode\runtimes\ansys\Scripts\python.exe`。如需使用其他受管解释器,使用机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON` 或 `ZCBOT_ADAPTER_ANSYS_PYTHON`;旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务和 runtime 集中保存在 `%ProgramData%\Zcbot\WindowsNode\`,不会因替换程序目录而丢失。运行时固定依赖见各 adapter 的 `requirements.txt`;任务请求无权选择解释器、脚本或路径。Origin Worker 支持 CSV/XLSX/JSON 输入及 OPJU/PNG/SVG/PDF 输出,成功产物发布到 `origin//`,plot spec 与 provenance 位于其 `.meta/`;上传中断会在重连时幂等续传。 + +ANSYS 第一阶段能力为 `ansys.mechanical.static_structural@v1`,只面向 Mechanical 2024 R2(revision 242),且注册码必须由管理员显式允许该 capability。当前交付先完成契约、安装探测、请求语义校验和执行安全门,默认 probe 返回不可用;在目标机器完成真实许可证与基准算例验收前,不得设置 `ZCBOT_ANSYS_242_VALIDATED=1`。验收至少包括:一个带 `fixed_face/load_face` Named Selection 的 STEP/Parasolid 几何能够完成线性静力求解并导出 MECHDAT、汇总、结果表和应力/变形图;取消能终止完整进程树;连续 20 次执行无残留 Mechanical/solver 进程且许可证均释放。验收通过后才以机器级环境变量开启执行门并重启托盘 Node。当前 Worker 的真实求解实现仍待该专用节点联调,开启安全门不会绕过 `ANSYS_242_SOLVER_IMPLEMENTATION_PENDING`。 Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动任务或完成后的自动分析约 4 秒刷新一次,空闲时降为约 30 秒;卡片优先显示状态、所属对话、输入输出、完成策略和耗时,Job ID、输出目录、执行节点及软件版本收在“任务详情”中。成功任务可查看结果目录、复制完整 Job ID、深入分析或重新分析。停止已派发任务是协作取消,状态先显示“正在停止”,Node 在线时立即接收,断线后在下次连接或心跳时重放。Agent 可调用 `software_capability_list`、`register_artifact`、`software_job_submit`、`software_job_status`、`software_job_revise` 和 `software_job_cancel`。Origin 输入必须是 artifact:已有 UUID 可直接提交,普通 task 文件先逐个用相对路径登记;登记不会发布聊天交付卡片。提交工具接收 `inputs`、`operation`、`outputs` 和可选的 `completion_action=report|analyze`:只要求生成或保存时用默认 `report`,任务成功后平台向原对话写入固定产物报告;明确要求解释、总结或结论时用 `analyze`,平台在原 task 空闲后自动续跑 Agent。两者均不阻塞提交轮次。请求支持 1–16 个输入、跨输入系列和多个显式输出;`plot.canvas` 可指定毫米画布,轴可指定范围、步长、尺度、刻度角度/字号、标题字号和网格,`legend` 可控制显隐、位置和字号,`series[].style` 可控制颜色、线宽/线型、点型/点大小和透明度。所有排版字段可选,旧请求保持默认样式。成功状态提供 `output_dir`,正式输出的 artifact 带 `software_job_id`,供结果卡和产物详情展示来源。Node 输出上传的逐任务诊断日志位于 `%ProgramData%\Zcbot\WindowsNode\jobs\\logs\node-output-upload.log`;日志包含上传阶段、产物文件名、重试次数和 Windows `HRESULT`,单文件达到 1 MiB 后轮转一份 `.1`,不记录 Node Token 或认证请求头。 @@ -1110,7 +1112,7 @@ Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动 常用二维扩展单图 type 包括 `area`、`polar`、`pie`,均使用 `input/x/y`;其中 polar 的 X 是角度(度)、Y 是半径,pie 的 X 是扇区标签、Y 是数值,两者使用 Origin 原生坐标/标签系统且不接受笛卡尔 `x_axis/y_axis/z_axis` 参数。`stacked_area`、`stacked_bar` 与 `stacked_column` 一样至少提供两条 `input/x/y`。堆叠系列可以来自不同输入,但行数和逐行 X 值必须完全一致,否则 Worker 在启动绘图前以 `STACKED_PLOT_X_VALUES_MISMATCH` 拒绝,避免错位累计。统计图与这些扩展图暂不混入 `multi_panel.series[].kind`。 -专业软件契约位于 `software-contracts/*.json`。zcbot 会在文件变化后校验并热加载完整契约快照,正常修改契约不需要重启服务;非法或尚未写完的文件不会替换上一份有效快照。Node Host 从 EXE 同级的 `adapters/*/adapter.json` 发现本机能力,通用校验只读取 manifest 指向的 JSON Schema;Worker 可以是受管 Python 脚本或独立 EXE。 +专业软件契约位于 `software-contracts/*.json`。zcbot 会在文件变化后校验并热加载完整契约快照,正常修改契约不需要重启服务;非法或尚未写完的文件不会替换上一份有效快照。Node Host 从 EXE 同级的 `adapters/*/adapter.json` 发现本机能力,通用校验只读取 manifest 指向的 JSON Schema;完整包把所有共享契约放入 EXE 同级 `software-contracts/`,adapter 独立更新包也可携带同名契约副本。manifest 可用 `worker_timeout_minutes` 为每种软件声明 1–1440 分钟的执行上限;旧 manifest 未声明时继续使用 30 分钟。Worker 可以是受管 Python 脚本或独立 EXE。 两个 JSON 的职责不同:`adapter.json` 只描述本机如何启动(实际版本、runtime、入口和契约文件名),`origin.plot.v2.json` 描述云端与本机共同遵守的业务请求/输出契约。前者不能替代后者;独立打包脚本只是把根目录的同一份业务契约复制进 adapter 交付目录,不维护第二份源码。 diff --git a/software-contracts/ansys.mechanical.static_structural.v1.json b/software-contracts/ansys.mechanical.static_structural.v1.json new file mode 100644 index 0000000..0b9bc25 --- /dev/null +++ b/software-contracts/ansys.mechanical.static_structural.v1.json @@ -0,0 +1,244 @@ +{ + "capability": "ansys.mechanical.static_structural@v1", + "display_name": "ANSYS Mechanical 线性静力分析", + "default_enrollment": false, + "output_namespace": "ansys", + "input_policy": { + "suffixes": [".step", ".stp", ".x_t", ".x_b", ".iges", ".igs"], + "max_count": 1, + "max_bytes": 536870912, + "max_total_bytes": 536870912 + }, + "outputs": { + "project": { + "filename": "project.mechdat", + "media_type": "application/octet-stream", + "relative_path": "project.mechdat", + "publish": true, + "required": false + }, + "summary": { + "filename": "summary.json", + "media_type": "application/json", + "relative_path": "summary.json", + "publish": true, + "required": true + }, + "result_table": { + "filename": "result-table.csv", + "media_type": "text/csv", + "relative_path": "result-table.csv", + "publish": true, + "required": true + }, + "stress_image": { + "filename": "equivalent-stress.png", + "media_type": "image/png", + "relative_path": "equivalent-stress.png", + "publish": true, + "required": false + }, + "deformation_image": { + "filename": "total-deformation.png", + "media_type": "image/png", + "relative_path": "total-deformation.png", + "publish": true, + "required": false + }, + "solver_log": { + "filename": "solver-output.txt", + "media_type": "text/plain", + "relative_path": "solver-output.txt", + "publish": true, + "required": false + }, + "analysis_spec": { + "filename": "analysis-spec.json", + "media_type": "application/json", + "relative_path": ".meta/analysis-spec.json", + "publish": false, + "required": true + }, + "provenance": { + "filename": "provenance.json", + "media_type": "application/json", + "relative_path": ".meta/provenance.json", + "publish": false, + "required": true + } + }, + "feature_path": ["operation", "analysis", "type"], + "features": { + "static_structural": "0.1.0" + }, + "summary": { + "title_path": ["operation", "analysis", "title"] + }, + "legacy_runtime": null, + "request_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "x-maxBytes": 131072, + "required": ["schema_version", "inputs", "operation", "outputs"], + "additionalProperties": false, + "properties": { + "schema_version": {"const": 1}, + "inputs": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "object", + "required": ["key", "artifact_id"], + "additionalProperties": false, + "properties": { + "key": {"const": "geometry"}, + "artifact_id": {"type": "string", "format": "uuid"} + } + } + }, + "operation": { + "type": "object", + "required": ["analysis"], + "additionalProperties": false, + "properties": { + "analysis": { + "type": "object", + "required": [ + "type", "title", "unit_system", "geometry", "material", "mesh", + "boundary_conditions", "loads", "results" + ], + "additionalProperties": false, + "properties": { + "type": {"const": "static_structural"}, + "title": {"type": "string", "minLength": 1, "maxLength": 160}, + "unit_system": {"enum": ["m_kg_s", "mm_kg_s"]}, + "geometry": { + "type": "object", + "required": ["input"], + "additionalProperties": false, + "properties": {"input": {"const": "geometry"}} + }, + "material": {"const": "structural_steel"}, + "mesh": { + "type": "object", + "required": ["global_size"], + "additionalProperties": false, + "properties": { + "global_size": {"type": "number", "exclusiveMinimum": 0, "maximum": 1000000} + } + }, + "boundary_conditions": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": {"$ref": "#/$defs/fixed_support"} + }, + "loads": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "oneOf": [ + {"$ref": "#/$defs/force"}, + {"$ref": "#/$defs/pressure"} + ] + } + }, + "results": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "uniqueItems": true, + "items": { + "enum": ["total_deformation", "equivalent_stress", "reaction_force"] + } + } + } + } + } + }, + "outputs": { + "type": "array", + "minItems": 0, + "maxItems": 5, + "uniqueItems": true, + "items": { + "oneOf": [ + {"$ref": "#/$defs/project_output"}, + {"$ref": "#/$defs/stress_image_output"}, + {"$ref": "#/$defs/deformation_image_output"}, + {"$ref": "#/$defs/solver_log_output"} + ] + } + } + }, + "$defs": { + "named_selection": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[^/\\\\:*?\"<>|]+$" + }, + "vector3": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": {"type": "number", "minimum": -1000000000000, "maximum": 1000000000000} + }, + "fixed_support": { + "type": "object", + "required": ["type", "named_selection"], + "additionalProperties": false, + "properties": { + "type": {"const": "fixed_support"}, + "named_selection": {"$ref": "#/$defs/named_selection"} + } + }, + "force": { + "type": "object", + "required": ["type", "named_selection", "components"], + "additionalProperties": false, + "properties": { + "type": {"const": "force"}, + "named_selection": {"$ref": "#/$defs/named_selection"}, + "components": {"$ref": "#/$defs/vector3"} + } + }, + "pressure": { + "type": "object", + "required": ["type", "named_selection", "magnitude"], + "additionalProperties": false, + "properties": { + "type": {"const": "pressure"}, + "named_selection": {"$ref": "#/$defs/named_selection"}, + "magnitude": {"type": "number", "minimum": -1000000000000, "maximum": 1000000000000} + } + }, + "project_output": { + "type": "object", + "required": ["key", "format"], + "additionalProperties": false, + "properties": {"key": {"const": "project"}, "format": {"const": "mechdat"}} + }, + "stress_image_output": { + "type": "object", + "required": ["key", "format"], + "additionalProperties": false, + "properties": {"key": {"const": "stress_image"}, "format": {"const": "png"}} + }, + "deformation_image_output": { + "type": "object", + "required": ["key", "format"], + "additionalProperties": false, + "properties": {"key": {"const": "deformation_image"}, "format": {"const": "png"}} + }, + "solver_log_output": { + "type": "object", + "required": ["key", "format"], + "additionalProperties": false, + "properties": {"key": {"const": "solver_log"}, "format": {"const": "txt"}} + } + } + } +} diff --git a/tests/test_ansys_adapter.py b/tests/test_ansys_adapter.py new file mode 100644 index 0000000..d42314a --- /dev/null +++ b/tests/test_ansys_adapter.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +import unittest +from pathlib import Path +from uuid import uuid4 + +from core.software_contracts import SoftwareContractError, get_contract + + +ROOT = Path(__file__).resolve().parents[1] +ADAPTER = ( + ROOT / "windows-node" / "adapters" / "ansys.mechanical.static_structural@v1" +) +WORKER_PATH = ADAPTER / "worker.py" + + +def _load_worker(): + spec = importlib.util.spec_from_file_location("zcbot_ansys_worker", WORKER_PATH) + if spec is None or spec.loader is None: + raise RuntimeError("could not load ANSYS worker") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _request() -> dict: + return { + "schema_version": 1, + "inputs": [{"key": "geometry", "artifact_id": str(uuid4())}], + "operation": { + "analysis": { + "type": "static_structural", + "title": "coupon static analysis", + "unit_system": "mm_kg_s", + "geometry": {"input": "geometry"}, + "material": "structural_steel", + "mesh": {"global_size": 2.0}, + "boundary_conditions": [ + {"type": "fixed_support", "named_selection": "fixed_face"} + ], + "loads": [ + { + "type": "force", + "named_selection": "load_face", + "components": [0, 0, -1000], + } + ], + "results": ["total_deformation", "equivalent_stress"], + } + }, + "outputs": [ + {"key": "project", "format": "mechdat"}, + {"key": "stress_image", "format": "png"}, + ], + } + + +class AnsysContractTests(unittest.TestCase): + def test_contract_is_not_enrolled_by_default_and_normalizes_static_job(self) -> None: + contract = get_contract("ansys.mechanical.static_structural@v1") + normalized, digest = contract.normalize_request(_request()) + self.assertFalse(contract.default_enrollment) + self.assertEqual(contract.feature(normalized), "static_structural") + self.assertEqual(contract.required_adapter_version(normalized), "0.1.0") + self.assertEqual(len(digest), 64) + self.assertEqual( + set(contract.expected_outputs(normalized)), + { + "project", + "stress_image", + "summary", + "result_table", + "analysis_spec", + "provenance", + }, + ) + + def test_contract_rejects_code_paths_and_unsupported_analysis(self) -> None: + contract = get_contract("ansys.mechanical.static_structural@v1") + for key, value in ( + ("script", "print('unsafe')"), + ("apdl", "/SOLU"), + ("path", r"C:\\private\\model.step"), + ): + request = _request() + request[key] = value + with self.assertRaises(SoftwareContractError): + contract.normalize_request(request) + request = _request() + request["operation"]["analysis"]["type"] = "modal" + with self.assertRaises(SoftwareContractError): + contract.normalize_request(request) + + +class AnsysWorkerTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.worker = _load_worker() + + def test_worker_semantics_reject_zero_force_and_reused_target(self) -> None: + request = _request() + request["operation"]["analysis"]["loads"][0]["components"] = [0, 0, 0] + with self.assertRaisesRegex(ValueError, "FORCE_VECTOR_MUST_BE_NONZERO"): + self.worker._validate_semantics(request) + + request = _request() + request["operation"]["analysis"]["loads"][0]["named_selection"] = "FIXED_FACE" + with self.assertRaisesRegex(ValueError, "NAMED_SELECTION_TARGETS_MUST_BE_UNIQUE"): + self.worker._validate_semantics(request) + + def test_probe_is_json_and_does_not_claim_ready_off_windows(self) -> None: + completed = subprocess.run( + [sys.executable, str(WORKER_PATH), "--probe"], + check=True, + capture_output=True, + text=True, + ) + value = json.loads(completed.stdout) + self.assertEqual(value["adapter_version"], "0.1.0") + self.assertEqual(value["software"], "ANSYS Mechanical") + if sys.platform != "win32": + self.assertEqual(value["health"], "unavailable") + + def test_execution_is_guarded_until_real_license_acceptance(self) -> None: + source = WORKER_PATH.read_text(encoding="utf-8") + self.assertIn('EXECUTION_GATE = "ZCBOT_ANSYS_242_VALIDATED"', source) + self.assertIn("ANSYS_242_REAL_LICENSE_ACCEPTANCE_REQUIRED", source) + for forbidden in ("subprocess", "eval(", "exec(", "os.system"): + self.assertNotIn(forbidden, source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_software_job_tools.py b/tests/test_software_job_tools.py index b1175fd..6bdc2e0 100644 --- a/tests/test_software_job_tools.py +++ b/tests/test_software_job_tools.py @@ -39,7 +39,13 @@ class SoftwareJobToolTests(unittest.TestCase): result = json.loads( SoftwareCapabilityListTool(self.user_id, self.task_id).execute() ) + self.assertEqual(result["capabilities"][0]["capability"], "origin.plot@v2") self.assertEqual(result["capabilities"][0]["available_nodes"], 1) + self.assertEqual( + result["capabilities"][1]["capability"], + "ansys.mechanical.static_structural@v1", + ) + self.assertEqual(result["capabilities"][1]["available_nodes"], 0) def test_submit_injects_current_user_and_task(self): created = {"job_id": str(uuid4()), "status": "queued"} diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py index 1cb0f90..f0a3ffc 100644 --- a/tests/test_windows_node_source.py +++ b/tests/test_windows_node_source.py @@ -126,18 +126,25 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertIn("py.exe -3.12", script) self.assertIn("sys.version_info[:2] == (3, 12)", script) self.assertIn( - 'set "RUNTIME_DIR=%ProgramData%\\Zcbot\\WindowsNode\\runtimes\\origin"', + 'set "ORIGIN_RUNTIME_DIR=%ProgramData%\\Zcbot\\WindowsNode\\runtimes\\origin"', script, ) - self.assertIn('set "RUNTIME_PYTHON=%RUNTIME_DIR%\\Scripts\\python.exe"', script) - self.assertIn('call :run_python -m venv "%RUNTIME_DIR%"', script) + self.assertIn( + 'set "ANSYS_RUNTIME_DIR=%ProgramData%\\Zcbot\\WindowsNode\\runtimes\\ansys"', + script, + ) + self.assertIn(":detect_ansys_242", script) + self.assertIn("v242\\aisol\\bin\\winx64\\AnsysWBU.exe", script) + self.assertIn('call :run_python -m venv "%ORIGIN_RUNTIME_DIR%"', script) + self.assertIn('call :run_python -m venv "%ANSYS_RUNTIME_DIR%"', script) def test_publish_output_contains_the_complete_installer_payload(self) -> None: project = (PROJECT / "Zcbot.WindowsNode.csproj").read_text(encoding="utf-8") self.assertIn("..\\install-windows-node.bat", project) - self.assertIn("..\\adapters\\origin.plot@v2\\requirements.txt", project) - self.assertIn("..\\adapters\\origin.plot@v2\\adapter.json", project) - self.assertIn("adapters\\origin.plot@v2\\origin.plot.v2.json", project) + self.assertIn("..\\adapters\\**\\*", project) + self.assertIn("adapters\\%(RecursiveDir)%(Filename)%(Extension)", project) + self.assertIn("..\\..\\software-contracts\\*.json", project) + self.assertIn("software-contracts\\%(Filename)%(Extension)", project) self.assertFalse((ROOT / "install-windows-node.ps1").exists()) self.assertFalse((ROOT / "install-origin-runtime.ps1").exists()) self.assertFalse((ROOT / "install-startup.ps1").exists()) @@ -149,7 +156,8 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertIn('if /I "%~1"=="--self-contained"', script) self.assertIn("dotnet publish", script) self.assertIn('"install-windows-node.bat"', script) - self.assertIn('"adapters\\origin.plot@v2\\origin.plot.v2.json"', script) + self.assertIn('for /r "!PUBLISH_DIR!\\adapters" %%F in (adapter.json)', script) + self.assertIn('"!PUBLISH_DIR!\\software-contracts\\*.json"', script) self.assertIn("tar.exe -a -c -f", script) self.assertIn("certutil.exe -hashfile", script) self.assertFalse((ROOT / "package-windows-node.ps1").exists()) @@ -266,7 +274,13 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertIn("process.Kill(entireProcessTree: true)", runner) self.assertIn('type.GetString() == "job_cancel"', connection) self.assertIn('"cancelled", "USER_CANCELLED"', connection) - self.assertIn("adapters\\origin.plot@v2\\worker.py", project) + self.assertIn("..\\adapters\\**\\*", project) + self.assertIn("descriptor.Manifest.WorkerTimeoutMinutes", runner) + adapters = (PROJECT / "NodeAdapters.cs").read_text(encoding="utf-8") + self.assertIn( + 'OptionalInteger(manifestRoot, "worker_timeout_minutes", 30, 1, 1440)', + adapters, + ) self.assertIn("if op.oext:", worker) self.assertIn("op.exit()", worker) self.assertIn("op.new_graph", worker) diff --git a/tools/software_jobs.py b/tools/software_jobs.py index b193cd1..5a26524 100644 --- a/tools/software_jobs.py +++ b/tools/software_jobs.py @@ -58,7 +58,8 @@ class SoftwareCapabilityListTool(_SoftwareJobTool): and item in (node.get("capabilities") or []) and node_available_slots(item, node.get("runtime") or {}) > 0 ), - } for item in sorted(supported_capabilities())] + } for item in supported_capabilities()] + items.sort(key=lambda item: (-item["available_nodes"], item["capability"])) return json.dumps({"capabilities": items}, ensure_ascii=False) diff --git a/windows-node/README.md b/windows-node/README.md index 667a420..eb1a234 100644 --- a/windows-node/README.md +++ b/windows-node/README.md @@ -2,7 +2,7 @@ 内网 MVP 的 Windows 执行节点,目标运行环境为 Windows 11 Enterprise + .NET 10 SDK 10.0.303。仓库根目录 `global.json` 固定 SDK patch;客户端只使用 .NET Windows Desktop Framework,不依赖第三方 NuGet 包。 -当前实现托盘状态角标、配置与本机任务窗口、注册、DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连。Node Host 以语言无关的 job 目录协议负责持久化、下载、恢复、取消和上传,并从 EXE 同级 `adapters/*/adapter.json` 发现能力;Host 只按 manifest 指向的 JSON Schema 通用校验请求,软件探测、语义校验和执行都由 Worker 完成。Worker 可为受管 Python 脚本或独立 EXE。当前安装包带 `origin.plot@v2` Python Worker,但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录;成功产物按 manifest 上传并由云端复核,全部完成后原子发布,中断后按本地 `upload-complete.json` 幂等续传。 +当前实现托盘状态角标、配置与本机任务窗口、注册、DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连。Node Host 以语言无关的 job 目录协议负责持久化、下载、恢复、取消和上传,并从 EXE 同级 `adapters/*/adapter.json` 发现能力;Host 只按 manifest 指向的 JSON Schema 通用校验请求,软件探测、语义校验和执行都由 Worker 完成。Worker 可为受管 Python 脚本或独立 EXE,manifest 独立声明每种软件的任务超时。当前安装包带 `origin.plot@v2` 和受真机验收门保护的 `ansys.mechanical.static_structural@v1` Python Worker,但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录;成功产物按 manifest 上传并由云端复核,全部完成后原子发布,中断后按本地 `upload-complete.json` 幂等续传。 在仓库根目录执行一条命令生成可分发 ZIP: @@ -20,7 +20,9 @@ windows-node\package-windows-node.bat install-windows-node.bat ``` -安装器是纯 BAT,不依赖 PowerShell:它先通过注册表检测本机软件;检测到 Origin/OriginPro 或其 COM 自动化组件时,依次尝试 `py -3.12` 和 PATH 中的 `python.exe`,安装 Origin runtime;未检测到则明确跳过。最后通过 `schtasks.exe` 为当前专用账号注册登录后启动任务,成功或失败后都会保留窗口。Python 未加入 PATH 时,可在命令提示符执行 `install-windows-node.bat "C:\Python312\python.exe"`。默认 runtime 位于 `%ProgramData%\Zcbot\WindowsNode\runtimes\origin\Scripts\python.exe`。 +安装器是纯 BAT,不依赖 PowerShell:它分别检测 Origin/OriginPro 和 ANSYS Mechanical 2024 R2,依次尝试 `py -3.12` 和 PATH 中的 `python.exe`,并安装隔离的 `origin`/`ansys` runtime;未检测到的软件会明确跳过。最后通过 `schtasks.exe` 为当前专用账号注册登录后启动任务,成功或失败后都会保留窗口。Python 未加入 PATH 时,可在命令提示符执行 `install-windows-node.bat "C:\Python312\python.exe"`。runtime 位于 `%ProgramData%\Zcbot\WindowsNode\runtimes\\Scripts\python.exe`。 + +ANSYS adapter 0.1.0 当前只完成 Mechanical 2024 R2 安装/绑定 probe、声明式静力分析契约、二次语义校验、独立 240 分钟超时和真机验收门。契约只接收已登记的 STEP/Parasolid/IGES 几何、Named Selection、固定材料/网格/支撑/力或压力及受控结果,不接收 Python、APDL、Journal、工程文件或本机路径。完成专用节点的许可证、基准算例、取消、残留进程和连续 20 次执行验收前保持不可调度;真实 Mechanical 求解器接线属于下一阶段。 注册和运行必须使用同一专用 Windows 账号。MVP 通过该账号的登录后计划任务自动启动,不以 Windows Service 在 Session 0 运行。 diff --git a/windows-node/Zcbot.WindowsNode/AdapterProcessRunner.cs b/windows-node/Zcbot.WindowsNode/AdapterProcessRunner.cs index a1c0cf6..a60d528 100644 --- a/windows-node/Zcbot.WindowsNode/AdapterProcessRunner.cs +++ b/windows-node/Zcbot.WindowsNode/AdapterProcessRunner.cs @@ -8,7 +8,6 @@ namespace Zcbot.WindowsNode; internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInboxStore inbox) { - private static readonly TimeSpan WorkerTimeout = TimeSpan.FromMinutes(30); private readonly ConcurrentDictionary active = new(); private readonly ConcurrentDictionary cancellations = new(); @@ -108,7 +107,7 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo var stdout = process.StandardOutput.ReadToEndAsync(); var stderr = process.StandardError.ReadToEndAsync(); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeout.CancelAfter(WorkerTimeout); + timeout.CancelAfter(TimeSpan.FromMinutes(descriptor.Manifest.WorkerTimeoutMinutes)); try { await process.WaitForExitAsync(timeout.Token); @@ -124,7 +123,7 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo { inbox.WriteTerminal( job, "failed", ErrorCode("WORKER_TIMEOUT"), - "Adapter worker exceeded 30 minutes."); + $"Adapter worker exceeded {descriptor.Manifest.WorkerTimeoutMinutes} minutes."); } return; } diff --git a/windows-node/Zcbot.WindowsNode/NodeAdapters.cs b/windows-node/Zcbot.WindowsNode/NodeAdapters.cs index eecc833..cbcbe55 100644 --- a/windows-node/Zcbot.WindowsNode/NodeAdapters.cs +++ b/windows-node/Zcbot.WindowsNode/NodeAdapters.cs @@ -25,6 +25,7 @@ internal sealed record AdapterManifest( string? RuntimeId, string Entrypoint, string Contract, + int WorkerTimeoutMinutes, string RunningDetail); internal sealed record NodeAdapterContract( @@ -47,7 +48,7 @@ internal sealed record AdapterDescriptor( var manifestRoot = manifestDocument.RootElement; RequireOnlyProperties( manifestRoot, "capability", "adapter_version", "runtime", "runtime_id", - "entrypoint", "contract", "running_detail"); + "entrypoint", "contract", "worker_timeout_minutes", "running_detail"); var manifest = new AdapterManifest( RequiredString(manifestRoot, "capability"), RequiredVersion(manifestRoot, "adapter_version"), @@ -55,6 +56,7 @@ internal sealed record AdapterDescriptor( OptionalString(manifestRoot, "runtime_id"), RequiredString(manifestRoot, "entrypoint"), RequiredString(manifestRoot, "contract"), + OptionalInteger(manifestRoot, "worker_timeout_minutes", 30, 1, 1440), RequiredString(manifestRoot, "running_detail")); if (!Regex.IsMatch(manifest.Capability, "^[a-z][a-z0-9_.-]+@v[1-9][0-9]*$")) { @@ -75,7 +77,7 @@ internal sealed record AdapterDescriptor( throw new InvalidDataException("Executable adapter must not declare runtime_id."); } - var contractPath = ResolveFile(root, manifest.Contract); + var contractPath = ResolveContractFile(root, manifest.Contract); using var contractDocument = JsonDocument.Parse(File.ReadAllBytes(contractPath)); var contractRoot = contractDocument.RootElement; var capability = RequiredString(contractRoot, "capability"); @@ -122,6 +124,30 @@ internal sealed record AdapterDescriptor( return resolved; } + private static string ResolveContractFile(string adapterRoot, string relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath) + || Path.IsPathFullyQualified(relativePath) + || Path.GetFileName(relativePath) != relativePath) + { + throw new InvalidDataException("Adapter contract path must be a relative filename."); + } + var local = Path.GetFullPath(Path.Combine(adapterRoot, relativePath)); + if (local.StartsWith(adapterRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + && File.Exists(local)) + { + return local; + } + var sharedRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "software-contracts")); + var shared = Path.GetFullPath(Path.Combine(sharedRoot, relativePath)); + if (!shared.StartsWith(sharedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || !File.Exists(shared)) + { + throw new InvalidDataException("Adapter contract is missing."); + } + return shared; + } + private static void RequireOnlyProperties(JsonElement value, params string[] names) { if (value.ValueKind != JsonValueKind.Object) @@ -153,6 +179,17 @@ internal sealed record AdapterDescriptor( return version; } + private static int OptionalInteger( + JsonElement value, string name, int defaultValue, int minimum, int maximum) + { + if (!value.TryGetProperty(name, out var property)) return defaultValue; + return property.TryGetInt32(out var result) + && result >= minimum + && result <= maximum + ? result + : throw new InvalidDataException($"Adapter property {name} is invalid."); + } + private static string? OptionalString(JsonElement value, string name) => !value.TryGetProperty(name, out var property) || property.ValueKind == JsonValueKind.Null ? null diff --git a/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj b/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj index a011862..3a1b1ee 100644 --- a/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj +++ b/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj @@ -15,23 +15,13 @@ - - adapters\origin.plot@v2\adapter.json + + adapters\%(RecursiveDir)%(Filename)%(Extension) PreserveNewest PreserveNewest - - adapters\origin.plot@v2\origin.plot.v2.json - PreserveNewest - PreserveNewest - - - adapters\origin.plot@v2\worker.py - PreserveNewest - PreserveNewest - - - adapters\origin.plot@v2\requirements.txt + + software-contracts\%(Filename)%(Extension) PreserveNewest PreserveNewest diff --git a/windows-node/adapters/ansys.mechanical.static_structural@v1/adapter.json b/windows-node/adapters/ansys.mechanical.static_structural@v1/adapter.json new file mode 100644 index 0000000..ee5eeae --- /dev/null +++ b/windows-node/adapters/ansys.mechanical.static_structural@v1/adapter.json @@ -0,0 +1,10 @@ +{ + "capability": "ansys.mechanical.static_structural@v1", + "adapter_version": "0.1.0", + "runtime": "python", + "runtime_id": "ansys", + "entrypoint": "worker.py", + "contract": "ansys.mechanical.static_structural.v1.json", + "worker_timeout_minutes": 240, + "running_detail": "ANSYS Mechanical 2024 R2 正在进行线性静力分析" +} diff --git a/windows-node/adapters/ansys.mechanical.static_structural@v1/requirements.txt b/windows-node/adapters/ansys.mechanical.static_structural@v1/requirements.txt new file mode 100644 index 0000000..e84f7a2 --- /dev/null +++ b/windows-node/adapters/ansys.mechanical.static_structural@v1/requirements.txt @@ -0,0 +1 @@ +ansys-mechanical-core==0.11.3 diff --git a/windows-node/adapters/ansys.mechanical.static_structural@v1/worker.py b/windows-node/adapters/ansys.mechanical.static_structural@v1/worker.py new file mode 100644 index 0000000..baf0f48 --- /dev/null +++ b/windows-node/adapters/ansys.mechanical.static_structural@v1/worker.py @@ -0,0 +1,124 @@ +"""Guarded ANSYS Mechanical 2024 R2 adapter bootstrap. + +The first implementation phase validates the fixed declarative job protocol and +probes the managed v242 runtime. Execution remains mechanically disabled until a +dedicated Windows node passes the real-license acceptance suite. The request can +never provide Python, APDL, journal text, executables, URLs, or local paths. +""" + +from __future__ import annotations + +import json +import math +import os +import sys +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +from typing import Any + +ADAPTER_VERSION = "0.1.0" +ANSYS_REVISION = 242 +ANSYS_RELEASE = "2024 R2" +EXECUTION_GATE = "ZCBOT_ANSYS_242_VALIDATED" +INSTALL_ROOT = Path(os.environ.get("AWP_ROOT242", r"C:\Program Files\ANSYS Inc\v242")) + + +def _probe() -> int: + health = "unavailable" + software_version = None + details: list[str] = [] + try: + if sys.platform != "win32": + raise RuntimeError("ANSYS adapter requires Windows") + if not INSTALL_ROOT.is_dir(): + raise RuntimeError("ANSYS Mechanical 2024 R2 installation was not found") + from ansys.mechanical.core import App # noqa: F401 + + package_version = version("ansys-mechanical-core") + software_version = ANSYS_RELEASE + details.append(f"PyMechanical {package_version} and v242 installation detected") + if os.environ.get(EXECUTION_GATE) != "1": + details.append("real-license acceptance is pending") + else: + health = "ready" + details.append("real-license acceptance gate is enabled") + except ( + FileNotFoundError, + ImportError, + OSError, + PackageNotFoundError, + RuntimeError, + ) as exc: + details = [str(exc)] + print( + json.dumps( + { + "adapter_version": ADAPTER_VERSION, + "software": "ANSYS Mechanical", + "software_version": software_version, + "health": health, + "detail": "; ".join(details), + }, + ensure_ascii=False, + ) + ) + return 0 + + +def _validate_semantics(request: dict[str, Any]) -> None: + analysis = request["operation"]["analysis"] + input_keys = {item["key"] for item in request["inputs"]} + if analysis["geometry"]["input"] not in input_keys: + raise ValueError("GEOMETRY_INPUT_NOT_BOUND") + + selections = [ + item["named_selection"] + for item in analysis["boundary_conditions"] + analysis["loads"] + ] + if len({item.casefold() for item in selections}) != len(selections): + raise ValueError("NAMED_SELECTION_TARGETS_MUST_BE_UNIQUE") + + for load in analysis["loads"]: + values = load.get("components", [load.get("magnitude")]) + if not all(isinstance(item, (int, float)) and math.isfinite(item) for item in values): + raise ValueError("LOAD_VALUES_MUST_BE_FINITE") + if load["type"] == "force" and not any(item != 0 for item in values): + raise ValueError("FORCE_VECTOR_MUST_BE_NONZERO") + if load["type"] == "pressure" and load["magnitude"] == 0: + raise ValueError("PRESSURE_MUST_BE_NONZERO") + + output_keys = [item["key"] for item in request["outputs"]] + if len(set(output_keys)) != len(output_keys): + raise ValueError("OUTPUT_KEYS_MUST_BE_UNIQUE") + + +def _load_request(job_dir: Path) -> dict[str, Any]: + record = json.loads( + (job_dir / "request" / "request.json").read_text(encoding="utf-8") + ) + request = record.get("request") + if not isinstance(request, dict): + raise ValueError("JOB_REQUEST_MISSING") + _validate_semantics(request) + return record + + +def main() -> int: + if sys.argv[1:] == ["--probe"]: + return _probe() + if len(sys.argv) != 2: + print("[ERR] Usage: worker.py ", file=sys.stderr) + return 2 + + try: + _load_request(Path(sys.argv[1])) + if os.environ.get(EXECUTION_GATE) != "1": + raise RuntimeError("ANSYS_242_REAL_LICENSE_ACCEPTANCE_REQUIRED") + raise RuntimeError("ANSYS_242_SOLVER_IMPLEMENTATION_PENDING") + except (KeyError, OSError, ValueError, json.JSONDecodeError, RuntimeError) as exc: + print(f"[ERR] {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/windows-node/adapters/origin.plot@v2/adapter.json b/windows-node/adapters/origin.plot@v2/adapter.json index 8ead177..c77af77 100644 --- a/windows-node/adapters/origin.plot@v2/adapter.json +++ b/windows-node/adapters/origin.plot@v2/adapter.json @@ -5,5 +5,6 @@ "runtime_id": "origin", "entrypoint": "worker.py", "contract": "origin.plot.v2.json", + "worker_timeout_minutes": 30, "running_detail": "Origin 正在生成图形" } diff --git a/windows-node/install-windows-node.bat b/windows-node/install-windows-node.bat index 504094e..bd8e34a 100644 --- a/windows-node/install-windows-node.bat +++ b/windows-node/install-windows-node.bat @@ -3,9 +3,12 @@ setlocal EnableExtensions cd /d "%~dp0" set "NODE_EXE=%~dp0Zcbot.WindowsNode.exe" -set "REQUIREMENTS=%~dp0adapters\origin.plot@v2\requirements.txt" -set "RUNTIME_DIR=%ProgramData%\Zcbot\WindowsNode\runtimes\origin" -set "RUNTIME_PYTHON=%RUNTIME_DIR%\Scripts\python.exe" +set "ORIGIN_REQUIREMENTS=%~dp0adapters\origin.plot@v2\requirements.txt" +set "ORIGIN_RUNTIME_DIR=%ProgramData%\Zcbot\WindowsNode\runtimes\origin" +set "ORIGIN_RUNTIME_PYTHON=%ORIGIN_RUNTIME_DIR%\Scripts\python.exe" +set "ANSYS_REQUIREMENTS=%~dp0adapters\ansys.mechanical.static_structural@v1\requirements.txt" +set "ANSYS_RUNTIME_DIR=%ProgramData%\Zcbot\WindowsNode\runtimes\ansys" +set "ANSYS_RUNTIME_PYTHON=%ANSYS_RUNTIME_DIR%\Scripts\python.exe" set "PYTHON_EXE=" set "PYTHON_ARGS=" @@ -19,49 +22,93 @@ if not exist "%NODE_EXE%" ( call :detect_origin if errorlevel 1 ( echo [WARN] Origin/OriginPro was not detected; skipped the Origin runtime. - goto :install_startup + goto :install_ansys ) echo [INFO] Origin/OriginPro detected. -if not exist "%REQUIREMENTS%" ( - echo [ERR] Origin worker requirements are missing: %REQUIREMENTS% +if not exist "%ORIGIN_REQUIREMENTS%" ( + echo [ERR] Origin worker requirements are missing: %ORIGIN_REQUIREMENTS% goto :failed ) call :find_python "%~1" if errorlevel 1 goto :failed -if not exist "%RUNTIME_PYTHON%" ( +if not exist "%ORIGIN_RUNTIME_PYTHON%" ( echo [INFO] Creating Origin Python runtime... - call :run_python -m venv "%RUNTIME_DIR%" + call :run_python -m venv "%ORIGIN_RUNTIME_DIR%" if errorlevel 1 ( echo [ERR] Failed to create the Origin Python runtime. goto :failed ) ) -if not exist "%RUNTIME_PYTHON%" ( - echo [ERR] Python venv did not create its interpreter: %RUNTIME_PYTHON% +if not exist "%ORIGIN_RUNTIME_PYTHON%" ( + echo [ERR] Python venv did not create its interpreter: %ORIGIN_RUNTIME_PYTHON% goto :failed ) -"%RUNTIME_PYTHON%" -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)" +"%ORIGIN_RUNTIME_PYTHON%" -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)" if errorlevel 1 ( - echo [ERR] Existing Origin runtime is not Python 3.12: %RUNTIME_PYTHON% + echo [ERR] Existing Origin runtime is not Python 3.12: %ORIGIN_RUNTIME_PYTHON% goto :failed ) echo [INFO] Installing Origin worker dependencies... -"%RUNTIME_PYTHON%" -m pip install --requirement "%REQUIREMENTS%" +"%ORIGIN_RUNTIME_PYTHON%" -m pip install --requirement "%ORIGIN_REQUIREMENTS%" if errorlevel 1 ( echo [ERR] Failed to install the Origin worker dependencies. goto :failed ) -"%RUNTIME_PYTHON%" -c "import originpro, openpyxl, numpy, pymupdf; print('[OK] Origin worker Python packages are available.')" +"%ORIGIN_RUNTIME_PYTHON%" -c "import originpro, openpyxl, numpy, pymupdf; print('[OK] Origin worker Python packages are available.')" if errorlevel 1 ( echo [ERR] Origin runtime import verification failed. goto :failed ) -echo [OK] Origin runtime installed: %RUNTIME_PYTHON% +echo [OK] Origin runtime installed: %ORIGIN_RUNTIME_PYTHON% + +:install_ansys +call :detect_ansys_242 +if errorlevel 1 ( + echo [WARN] ANSYS Mechanical 2024 R2 was not detected; skipped the ANSYS runtime. + goto :install_startup +) + +echo [INFO] ANSYS Mechanical 2024 R2 detected. +if not exist "%ANSYS_REQUIREMENTS%" ( + echo [ERR] ANSYS worker requirements are missing: %ANSYS_REQUIREMENTS% + goto :failed +) +call :find_python "%~1" +if errorlevel 1 goto :failed +if not exist "%ANSYS_RUNTIME_PYTHON%" ( + echo [INFO] Creating ANSYS Python runtime... + call :run_python -m venv "%ANSYS_RUNTIME_DIR%" + if errorlevel 1 ( + echo [ERR] Failed to create the ANSYS Python runtime. + goto :failed + ) +) +if not exist "%ANSYS_RUNTIME_PYTHON%" ( + echo [ERR] Python venv did not create its interpreter: %ANSYS_RUNTIME_PYTHON% + goto :failed +) +"%ANSYS_RUNTIME_PYTHON%" -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)" +if errorlevel 1 ( + echo [ERR] Existing ANSYS runtime is not Python 3.12: %ANSYS_RUNTIME_PYTHON% + goto :failed +) +echo [INFO] Installing ANSYS worker dependencies... +"%ANSYS_RUNTIME_PYTHON%" -m pip install --requirement "%ANSYS_REQUIREMENTS%" +if errorlevel 1 ( + echo [ERR] Failed to install the ANSYS worker dependencies. + goto :failed +) +"%ANSYS_RUNTIME_PYTHON%" -c "from ansys.mechanical.core import App; print('[OK] ANSYS worker Python packages are available.')" +if errorlevel 1 ( + echo [ERR] ANSYS runtime import verification failed. + goto :failed +) +echo [OK] ANSYS runtime installed: %ANSYS_RUNTIME_PYTHON% :install_startup echo [INFO] Registering startup task for %USERDOMAIN%\%USERNAME%... @@ -86,6 +133,11 @@ reg.exe query "HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" /s /f " if not errorlevel 1 exit /b 0 exit /b 1 +:detect_ansys_242 +if defined AWP_ROOT242 if exist "%AWP_ROOT242%\aisol\bin\winx64\AnsysWBU.exe" exit /b 0 +if exist "C:\Program Files\ANSYS Inc\v242\aisol\bin\winx64\AnsysWBU.exe" exit /b 0 +exit /b 1 + :find_python if not "%~1"=="" ( if not exist "%~1" ( diff --git a/windows-node/package-windows-node.bat b/windows-node/package-windows-node.bat index f38db66..13cc2f6 100644 --- a/windows-node/package-windows-node.bat +++ b/windows-node/package-windows-node.bat @@ -63,16 +63,22 @@ if errorlevel 1 ( for %%F in ( "Zcbot.WindowsNode.exe" "install-windows-node.bat" - "adapters\origin.plot@v2\adapter.json" - "adapters\origin.plot@v2\worker.py" - "adapters\origin.plot@v2\requirements.txt" - "adapters\origin.plot@v2\origin.plot.v2.json" ) do ( if not exist "!PUBLISH_DIR!\%%~F" ( echo [ERR] Published package is incomplete: %%~F goto :failed ) ) +set "ADAPTER_COUNT=0" +for /r "!PUBLISH_DIR!\adapters" %%F in (adapter.json) do set /a ADAPTER_COUNT+=1 +if !ADAPTER_COUNT! LSS 1 ( + echo [ERR] Published package contains no adapters. + goto :failed +) +if not exist "!PUBLISH_DIR!\software-contracts\*.json" ( + echo [ERR] Published package contains no software contracts. + goto :failed +) tar.exe -a -c -f "!ARCHIVE_PATH!" -C "!OUTPUT_ROOT!" "!PACKAGE_NAME!" if errorlevel 1 (