diff --git a/CHANGELOG.md b/CHANGELOG.md index 7076f0d..1c8b5f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Unreleased +- Windows Node 的任务、Workspace 和受管运行时现在可以从界面整体迁移到其他本机磁盘;迁移完成前会校验全部文件并保留旧目录,管理员也可以用机器级环境变量统一指定位置。 + - Windows Node 新增 Blender 三维场景创作能力,首批可按长度、直径、斜度、支撑档数和附属部件生成参数化回转窑,并可继续调整工程、预览或导出 GLB。 - 软件作业列表不再因历史任务使用的旧能力版本已经下线而整体消失;旧任务仍可查看,列表请求失败时页面也会明确提示刷新重试。 diff --git a/DESIGN.md b/DESIGN.md index 8f3aa00..ff85b89 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -478,7 +478,7 @@ Recipe 是专业软件的声明式目标状态,不是任意脚本或逐次鼠 Blender 首个入口为 `blender.scene.author@v1`,以设备目标状态而非 Python、逐步按键或任意节点脚本表达三维创作。adapter 0.1.0 的首个 `rotary_kiln` feature 接受长度、直径、壳体/衬里厚度、斜度、支撑档数及驱动、燃烧器、窑头尾罩、平台、物料等受控参数,由固定 `bpy` Worker 创建模型、相机、灯光和材质;输出 `.blend`、PNG 预览、场景清单、溯源及可选 GLB。受管 Python 只负责发现并以 `--background --factory-startup --disable-autoexec` 拉起已安装的 Blender,实际 `bpy` 在 Blender 自带 Python 内执行;请求不能提供脚本、命令行、本机路径、URL 或插件。`.blend` 作为 Workspace state,续作先验证并打开上一版工程,再以完整目标状态生成新版本;后续修改器、节点、动画和高质量渲染继续作为同一业务方向的版本化 feature 增量开放,不把首个设备构造器固化成 Blender 的能力上限。 -专业软件的连续加工以独立 `software_workspaces` 为稳定边界,Job 只记录一次操作。首个 Job 创建独立 `workspace_id`,两者不共用 ID;服务端保存 user 归属、capability、`home_node_id`、`head_job_id`、容量与留存元数据,Node 物理目录只使用 `%ProgramData%/Zcbot/WindowsNode/workspaces//`,不再按 user 建上层目录。这样用户统计集中在数据库完成,节点只管理实际落到本机的 workspace,也避免多节点各自维护一套用户目录树。Workspace 一旦首次调度即粘在 home node;后续 Job 必须以当前 head 为 source、串行回到同一节点,节点离线时等待而不复制工程或静默改派。 +专业软件的连续加工以独立 `software_workspaces` 为稳定边界,Job 只记录一次操作。首个 Job 创建独立 `workspace_id`,两者不共用 ID;服务端保存 user 归属、capability、`home_node_id`、`head_job_id`、容量与留存元数据,Node 物理目录只使用 `/workspaces//`,不再按 user 建上层目录。`data_root` 默认是 `%ProgramData%/Zcbot/WindowsNode`,专用运行账号可在 UI 中选择其他本机固定磁盘,机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 拥有最高优先级并使 UI 只读;账号级选择保存在 HKCU,避免把根目录定位信息放入其自身。迁移先停止调度并等待本机任务及导出收尾,在目标同级临时目录复制并逐文件校验后才原子切换,旧目录不自动删除。这样用户统计集中在数据库完成,节点只管理实际落到本机的 workspace,也避免多节点各自维护一套用户目录树。Workspace 一旦首次调度即粘在 home node;后续 Job 必须以当前 head 为 source、串行回到同一节点,节点离线时等待而不复制工程或静默改派。 Node 对每个 Workspace 只保留 `current` 和 `rollback` 两代。续作开始前把 `current` 原子切换为 `rollback`,Worker 打开该工程并将新状态写入 Job 输出区;成功后输出区提升为新 `current`,失败或进程中断则恢复 `rollback`。因此连续加工不会为每个 Job 永久复制一份工程,物理空间上限约为当前工程加一份回滚工程;Job 账本仍完整保留参数、摘要和 local manifest。`software_job_revise(source_job_id, operation, outputs)` 只允许从 Workspace 当前 head 继续,复用已登记输入并重新经过当前契约校验。当前 Origin adapter 以 OPJU 为 workspace state;ANSYS 静力 capability 仍是无状态一次性执行,契约中的 `workspace: null` 明确保持原发布流程。 diff --git a/PROGRESS.md b/PROGRESS.md index 0329ce3..491ecfa 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-20(Blender 回转窑 adapter 已完成本机烟测,未发版) +最后更新:2026-08-20(Windows Node 数据根目录可安全迁移,未发版) --- @@ -20,6 +20,8 @@ --- ## 已完成关键能力 +- **08-20 / Unreleased / Windows Node 数据根目录自定义与安全迁移**:新增 UI 数据目录选择、打开和整体迁移,机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 优先且使 UI 只读,未配置节点继续使用 `%ProgramData%`;迁移拒绝活动任务、非固定盘、嵌套或非空目标及空间不足,停止连接并等待任务/导出收尾后在临时目录复制和逐文件 SHA-256 校验,成功才保存账号级路径并重启,旧目录保留。安装器、runtime、Workspace、诊断和磁盘余量上报统一跟随实际数据根目录;65 项 Windows Node/软件节点专项 unittest、.NET build 与 diff 检查通过,未迁移真实节点数据、未连接生产数据库。 + - **08-20 / Unreleased / Blender 参数化回转窑 adapter**:新增 `blender.scene.author@v1` 与 `rotary_kiln` 声明式场景,可按长度、直径、壳体/衬里厚度、斜度、支撑档数及驱动、燃烧器、罩体、平台和物料参数生成可续作 `.blend` 工程、PNG 预览、场景清单、溯源和可选 GLB;固定启动器以后台、factory startup、禁止自动脚本模式调用 Blender,自身不接收 Python/命令行/路径。已在 Blender 3.6.12 上完成 60 m × 4 m、3.5% 斜度、三档生成及三档到四档 Workspace 续作真机烟测;专项 93 项 unittest、.NET build/publish 和独立 adapter 打包通过,全量 693 项仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项),未连接或迁移生产数据库。 - **08-20 / Unreleased / ANSYS 几何检查 + 静力选区 v2**:新增 `ansys.geometry.inspect@v1`,由 Mechanical 真实导入后输出 assembly/body/face、几何单位、包围盒、面质心/法向/面积、已有 Named Selection、预览和溯源;`ansys.mechanical.static_structural@v2` 以已有命名选择、全局轴极值面或平面法向/偏置/容差解析边界区域,支持 `expected_count` 硬断言,按实体 ID 创建项目内选择集,并把最终面和选区预览纳入结果。按明确授权直接移除 v1 调度能力;90 项 ANSYS/合同/Job/Node 专项 unittest、.NET build 与发布目录检查通过;全量 684 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项),未连接 ANSYS 或生产数据库,v2 真实许可证、选区和连续释放验收仍须在目标节点重跑。 diff --git a/RUN.md b/RUN.md index d4c8901..9feccb2 100644 --- a/RUN.md +++ b/RUN.md @@ -1098,9 +1098,11 @@ cd /d D:\ZcbotNode install-windows-node.bat ``` -若 Python 未加入 PATH,可把绝对路径作为第一个参数,例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认解释器分别位于 `%ProgramData%\Zcbot\WindowsNode\runtimes\origin|ansys|blender\Scripts\python.exe`。如需使用其他受管解释器,使用机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON`、`ZCBOT_ADAPTER_ANSYS_PYTHON` 或 `ZCBOT_ADAPTER_BLENDER_PYTHON`;旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务、workspace 和 runtime 集中保存在 `%ProgramData%\Zcbot\WindowsNode\`,不会因替换程序目录而丢失。运行时固定依赖见各 adapter 的 `requirements.txt`;任务请求无权选择解释器、脚本或路径。 +若 Python 未加入 PATH,可把绝对路径作为第一个参数,例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认数据根目录是 `%ProgramData%\Zcbot\WindowsNode`,解释器位于 `\runtimes\origin|ansys|blender\Scripts\python.exe`。如需使用其他受管解释器,使用机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON`、`ZCBOT_ADAPTER_ANSYS_PYTHON` 或 `ZCBOT_ADAPTER_BLENDER_PYTHON`;旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务、workspace 和 runtime 集中保存在同一数据根目录,不会因替换程序目录而丢失。运行时固定依赖见各 adapter 的 `requirements.txt`;任务请求无权选择解释器、脚本或路径。 -Workspace 目录固定为 `%ProgramData%\Zcbot\WindowsNode\workspaces\\`,不使用 user ID 上层目录。`current` 是当前可续作工程,`rollback` 只保留上一版用于失败恢复;每次成功续作替换 `current`,不会按 Job 永久堆积工程副本。不要手工移动 workspace 到其他节点:服务端会把后续 Job 固定调度到其 home node,节点离线时任务保持等待。备份或迁移节点时必须把数据库中的 workspace 归属和该目录作为一个整体处理。 +数据根目录可在 Node 主窗口“运行设置”中迁移到其他本机固定磁盘。已有未完成任务时 UI 会拒绝迁移;开始后先停止接收新任务,把整个根目录复制到目标同级临时目录并逐文件 SHA-256 校验,成功后才保存账号级路径并重启。旧目录不会自动删除,确认新目录正常后再由管理员手工清理。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 的优先级高于 UI 配置,存在时 UI 只读;设置或修改该环境变量后必须重启 Node,并由管理员自行保证完整数据已经迁入。统一安装器会读取同一环境变量或当前专用账号的 UI 配置。 + +Workspace 目录固定为 `\workspaces\\`,不使用 user ID 上层目录。`current` 是当前可续作工程,`rollback` 只保留上一版用于失败恢复;每次成功续作替换 `current`,不会按 Job 永久堆积工程副本。不要手工移动 workspace 到其他节点:服务端会把后续 Job 固定调度到其 home node,节点离线时任务保持等待。备份或迁移节点时必须把数据库中的 workspace 归属和该目录作为一个整体处理。 Blender 能力为 `blender.scene.author@v1`。首版 `rotary_kiln` 接受完整的回转窑目标状态,生成 `scene.blend`、`preview.png`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb`;`.blend` 留在 Workspace 作为下一次修改的事实源。安装器会从 `ZCBOT_BLENDER_EXE`、PATH 或 `%ProgramFiles%\Blender Foundation\Blender *\blender.exe` 探测 Blender;非标准安装位置应先设置机器级 `ZCBOT_BLENDER_EXE`,再重新启动 Node。启动器固定使用 Blender 后台、factory startup 和禁止自动脚本模式,不读取用户启动文件,也不接受请求传入 Python、插件、命令行、URL 或本机路径。 @@ -1113,7 +1115,7 @@ ANSYS 能力为 `ansys.geometry.inspect@v1` 和 `ansys.mechanical.static_structu 节点卡片的“复制诊断信息”可把 Node 版本、系统、数据目录、执行门状态和每个 adapter 的软件版本、health、detail、受管 runtime 路径复制到对话,不包含 Node Token。若 UI 无法运行验收,可先退出托盘 Node,再用同一专用 Windows 账号在 PowerShell 执行以下等价命令(`--work-root` 必须是尚不存在的新目录): ```powershell -& "$env:ProgramData\Zcbot\WindowsNode\runtimes\ansys\Scripts\python.exe" ` +& "\runtimes\ansys\Scripts\python.exe" ` "D:\ZcbotNode\adapters\ansys.mechanical.static_structural@v2\acceptance.py" ` --work-root "D:\ZcbotAcceptance\run-20260818" ` --repeat 20 ` @@ -1131,7 +1133,7 @@ ANSYS 能力为 `ansys.geometry.inspect@v1` 和 `ansys.mechanical.static_structu 重启后 probe 应报告 `health=ready`,再从服务端提交一个同规格小算例验证调度、上传与发布闭环。取消验收若提示 `CANCELLATION_JOB_FINISHED_BEFORE_CANCEL`,说明基准在设定秒数前已经完成,应减小 `--cancel-after` 后使用新的 `--work-root` 重跑,而不是把该次视为通过。 -Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动任务或完成后的自动分析约 4 秒刷新一次,空闲时降为约 30 秒;卡片优先显示状态、所属对话、输入输出、完成策略和耗时,Job ID、Workspace ID、执行节点及软件版本收在“任务详情”中。停止已派发任务是协作取消,状态先显示“正在停止”,Node 在线时立即接收,断线后在下次连接或心跳时重放。Agent 可调用 `software_capability_list`、`register_artifact`、`software_job_submit`、`software_job_status`、`software_job_revise`、`software_job_export` 和 `software_job_cancel`。Origin 输入必须是 artifact:已有 UUID 可直接提交,普通 task 文件先逐个用相对路径登记;登记不会发布聊天交付卡片。提交工具接收 `inputs`、`operation`、`outputs` 和可选的 `completion_action=report|analyze`。Workspace 任务成功后只自动上传契约声明的 PNG 等轻量预览,预览保存在隐藏缓存且不注册 Artifact;OPJU、SVG、PDF 等完整输出继续留在 Node。只有用户明确要求下载、发送或交付时,才调用 `software_job_export(job_id, output_ids)`,Node 会上传所选输出并在 task 目录登记正式 Artifact。Node 输出上传的逐任务诊断日志位于 `%ProgramData%\Zcbot\WindowsNode\jobs\\logs\node-output-upload.log`;日志包含上传阶段、产物文件名、重试次数和 Windows `HRESULT`,单文件达到 1 MiB 后轮转一份 `.1`,不记录 Node Token 或认证请求头。 +Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动任务或完成后的自动分析约 4 秒刷新一次,空闲时降为约 30 秒;卡片优先显示状态、所属对话、输入输出、完成策略和耗时,Job ID、Workspace ID、执行节点及软件版本收在“任务详情”中。停止已派发任务是协作取消,状态先显示“正在停止”,Node 在线时立即接收,断线后在下次连接或心跳时重放。Agent 可调用 `software_capability_list`、`register_artifact`、`software_job_submit`、`software_job_status`、`software_job_revise`、`software_job_export` 和 `software_job_cancel`。Origin 输入必须是 artifact:已有 UUID 可直接提交,普通 task 文件先逐个用相对路径登记;登记不会发布聊天交付卡片。提交工具接收 `inputs`、`operation`、`outputs` 和可选的 `completion_action=report|analyze`。Workspace 任务成功后只自动上传契约声明的 PNG 等轻量预览,预览保存在隐藏缓存且不注册 Artifact;OPJU、SVG、PDF 等完整输出继续留在 Node。只有用户明确要求下载、发送或交付时,才调用 `software_job_export(job_id, output_ids)`,Node 会上传所选输出并在 task 目录登记正式 Artifact。Node 输出上传的逐任务诊断日志位于 `\jobs\\logs\node-output-upload.log`;日志包含上传阶段、产物文件名、重试次数和 Windows `HRESULT`,单文件达到 1 MiB 后轮转一份 `.1`,不记录 Node Token 或认证请求头。 二维复合图使用 `plot.type=multi_panel`。`layout` 支持 1×1、1×2、2×1 和 2×2,`panels` 数量为 1–4;两个 panel 必须选择横排或竖排,三个和四个 panel 使用 2×2。每个 panel 的 `series[]` 必须显式给出 `kind`(`line/scatter/line_scatter/column/bar`),可用 `y_axis=right` 绑定右 Y 轴,并用 `x_error`、`y_error` 指定对称误差列;顶层 `x_axis/y_axis/legend` 作为全部 panel 的默认值,panel 内同名设置可单独覆盖,`right_y_axis` 仍按 panel 设置。一个请求最多仍为 16 条系列,且每个 panel 至少有一条左轴系列。`share_x/share_y` 会统一各主图层自动缩放后的范围。复合图不接受 Origin 模板名,也不支持把等高线、3D 曲面、三元图或热图混入 panel。 @@ -1149,7 +1151,7 @@ Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动 Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打成 `dist\blender.scene.author@v1-adapter.zip`,替换规则与 Origin 相同。Blender 的 `requirements.txt` 当前不安装第三方包;首次增加 Blender 能力时仍需运行统一安装器创建轻量启动 runtime,`bpy` 不安装到该 runtime。 -注册配置写入 `%ProgramData%\Zcbot\WindowsNode\node.json`;Token 使用 DPAPI `LocalMachine` 加密,ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号执行统一安装器、注册并运行 Node。当前 MVP 以该账号的登录后计划任务启动,不安装 Windows Service。 +注册配置写入 `\node.json`;Token 使用 DPAPI `LocalMachine` 加密,ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号执行统一安装器、注册并运行 Node。当前 MVP 以该账号的登录后计划任务启动,不安装 Windows Service。 直接双击 EXE 启动托盘 UI:红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开配置窗。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless`。 diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py index 170e960..ac2bc21 100644 --- a/tests/test_windows_node_source.py +++ b/tests/test_windows_node_source.py @@ -63,11 +63,45 @@ class WindowsNodeSourceTests(unittest.TestCase): source = "\n".join( path.read_text(encoding="utf-8") for path in PROJECT.glob("*.cs") - if path.name != "AdapterProcessRunner.cs" + if path.name not in {"AdapterProcessRunner.cs", "ConfigurationForm.cs"} ) for forbidden in ("Process.Start", "cmd.exe", "powershell.exe", "LabTalk"): self.assertNotIn(forbidden, source) + form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8") + self.assertIn('"explorer.exe"', form) + self.assertIn("startInfo.ArgumentList.Add(dataRoot.Text)", form) + self.assertNotIn("UseShellExecute = true", form) + + def test_data_root_is_configurable_and_migrated_before_switching(self) -> None: + settings = (PROJECT / "NodeDataRoot.cs").read_text(encoding="utf-8") + models = (PROJECT / "NodeModels.cs").read_text(encoding="utf-8") + form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8") + tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8") + connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8") + + self.assertIn('"ZCBOT_WINDOWS_NODE_DATA_DIR"', settings) + self.assertLess( + settings.index("Environment.GetEnvironmentVariable"), + settings.index("Registry.CurrentUser.OpenSubKey"), + ) + self.assertIn("NodeDataRootSettings.Resolve().RootDirectory", models) + self.assertIn("SHA256.HashData(stream)", settings) + self.assertIn("Directory.Move(staging, target)", settings) + self.assertLess( + settings.index("VerifyTree(source, staging"), + settings.index("Directory.Move(staging, target)"), + ) + self.assertIn("DataRootMigrationRequested", form) + self.assertIn("jobInbox.HasPendingJobs", form) + self.assertIn( + "NodeDataRootSettings.SaveUserRoot(result.TargetDirectory)", tray + ) + self.assertIn("jobPipelines.Values.Concat(exportPipelines.Values)", connection) + self.assertIn( + "Path.GetPathRoot(NodePaths.ForCurrentMachine().RootDirectory)", connection + ) + def test_config_field_names_do_not_serialize_plain_node_token(self) -> None: models = (PROJECT / "NodeModels.cs").read_text(encoding="utf-8") stored_record = models.split("internal sealed record StoredNodeConfig", 1)[1].split(");", 1)[0] @@ -105,7 +139,10 @@ class WindowsNodeSourceTests(unittest.TestCase): ) self.assertIn('["--work-root", root, "--repeat", "20"', runner) self.assertIn("Adapter acceptance cannot run while a dispatched job is active", runner) - self.assertNotIn("Process.Start", form) + acceptance = form.split("private async Task RunAnsysAcceptanceAsync", 1)[1].split( + "private void EnableAnsysGate", 1 + )[0] + self.assertNotIn("Process.Start", acceptance) def test_configuration_window_is_resizable_and_dpi_safe(self) -> None: form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8") @@ -173,17 +210,23 @@ class WindowsNodeSourceTests(unittest.TestCase): def test_unified_installer_detects_origin_and_python_312(self) -> None: script = (ROOT / "install-windows-node.bat").read_text(encoding="utf-8") + self.assertIn('set "NODE_DATA_ROOT=%ZCBOT_WINDOWS_NODE_DATA_DIR%"', script) + self.assertIn('reg.exe query "HKCU\\Software\\Zcbot\\WindowsNode" /v DataRoot', script) + self.assertIn( + 'if not defined NODE_DATA_ROOT set "NODE_DATA_ROOT=%ProgramData%\\Zcbot\\WindowsNode"', + script, + ) self.assertIn(":detect_origin", script) self.assertIn('reg.exe query "HKCR\\Origin.ApplicationSI\\CLSID"', script) self.assertIn(":create_python_runtime", script) self.assertIn("py.exe -3.12", script) self.assertIn("sys.version_info[:2] == (3, 12)", script) self.assertIn( - 'set "ORIGIN_RUNTIME_DIR=%ProgramData%\\Zcbot\\WindowsNode\\runtimes\\origin"', + 'set "ORIGIN_RUNTIME_DIR=%NODE_DATA_ROOT%\\runtimes\\origin"', script, ) self.assertIn( - 'set "ANSYS_RUNTIME_DIR=%ProgramData%\\Zcbot\\WindowsNode\\runtimes\\ansys"', + 'set "ANSYS_RUNTIME_DIR=%NODE_DATA_ROOT%\\runtimes\\ansys"', script, ) self.assertIn(":detect_ansys_242", script) @@ -204,7 +247,7 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertIn("ZCBOT_BLENDER_EXE", script) self.assertIn("Blender Foundation\\Blender *", script) self.assertIn( - 'set "BLENDER_RUNTIME_DIR=%ProgramData%\\Zcbot\\WindowsNode\\runtimes\\blender"', + 'set "BLENDER_RUNTIME_DIR=%NODE_DATA_ROOT%\\runtimes\\blender"', script, ) self.assertIn( @@ -353,7 +396,10 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertNotIn('stage = "origin_running"', connection) self.assertIn("&& !job.UploadComplete", connection) self.assertIn("&& !job.CloudTerminal", connection) - self.assertIn("await Task.WhenAll(jobPipelines.Values.ToArray())", connection) + self.assertIn( + "await Task.WhenAll(jobPipelines.Values.Concat(exportPipelines.Values).ToArray())", + connection, + ) self.assertIn("StartJobPipeline(socket, job)", connection) self.assertIn('stage = "downloading_inputs"', connection) self.assertIn('Path.Combine(jobDirectory, "terminal.json")', inbox) diff --git a/windows-node/README.md b/windows-node/README.md index b431588..bdbede0 100644 --- a/windows-node/README.md +++ b/windows-node/README.md @@ -20,7 +20,9 @@ windows-node\package-windows-node.bat install-windows-node.bat ``` -安装器是纯 BAT,不依赖 PowerShell:它分别检测 Origin/OriginPro、ANSYS Mechanical 2024 R2 和 Blender,依次尝试 `py -3.12` 和 PATH 中的 `python.exe`,并安装隔离的 `origin`/`ansys`/`blender` runtime;未检测到的软件会明确跳过。Blender runtime 只是标准库启动器,`bpy` 始终在 Blender 自带 Python 内运行。Python 依赖默认通过清华 PyPI 镜像安装;需要改用官方源或院内镜像时,先设置 `ZCBOT_PIP_INDEX_URL`。最后通过 `schtasks.exe` 为当前专用账号注册登录后启动任务,成功或失败后都会保留窗口。Python 未加入 PATH 时,可在命令提示符执行 `install-windows-node.bat "C:\Python312\python.exe"`。runtime 位于 `%ProgramData%\Zcbot\WindowsNode\runtimes\\Scripts\python.exe`。 +安装器是纯 BAT,不依赖 PowerShell:它分别检测 Origin/OriginPro、ANSYS Mechanical 2024 R2 和 Blender,依次尝试 `py -3.12` 和 PATH 中的 `python.exe`,并安装隔离的 `origin`/`ansys`/`blender` runtime;未检测到的软件会明确跳过。Blender runtime 只是标准库启动器,`bpy` 始终在 Blender 自带 Python 内运行。Python 依赖默认通过清华 PyPI 镜像安装;需要改用官方源或院内镜像时,先设置 `ZCBOT_PIP_INDEX_URL`。最后通过 `schtasks.exe` 为当前专用账号注册登录后启动任务,成功或失败后都会保留窗口。Python 未加入 PATH 时,可在命令提示符执行 `install-windows-node.bat "C:\Python312\python.exe"`。runtime 位于 `\runtimes\\Scripts\python.exe`;数据根目录默认是 `%ProgramData%\Zcbot\WindowsNode`。 + +主窗口“运行设置”可把完整数据根目录迁移到其他本机固定磁盘。Node 会先停止接收任务,已有未完成任务时拒绝开始;随后把身份、任务、Workspace 和 runtime 复制到临时目录并逐文件校验,校验成功后才切换账号级配置并重启,旧目录不会自动删除。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 优先于 UI 设置;设置后 UI 只读,适合统一运维。统一安装器同时读取环境变量或该专用账号的 UI 配置,后续更新不会把 runtime 写回默认目录。 Blender adapter 0.1.0 以 `rotary_kiln` 目标状态生成参数化回转窑,支持长度、直径、壳体/耐火衬里厚度、斜度、2–8 档支撑,以及驱动、燃烧器、窑头尾罩、检修平台和窑内物料开关;输出可续作 `scene.blend`、自动预览、场景清单、溯源和可选 GLB。启动命令固定包含 background、factory startup 和禁止自动脚本标志,请求不接受 Python、命令行、路径、URL 或插件。只更新该 adapter 时运行 `package-blender-adapter.bat`,退出托盘 Node 后整体替换 `adapters\blender.scene.author@v1\` 并重启;若节点没有 `runtimes\blender`,再运行统一安装器。 @@ -37,10 +39,10 @@ windows-node/Zcbot.WindowsNode/bin/Debug/net10.0-windows/Zcbot.WindowsNode.exe e windows-node/Zcbot.WindowsNode/bin/Debug/net10.0-windows/Zcbot.WindowsNode.exe ``` -直接双击 EXE 默认启动托盘 UI;红点表示未注册或身份失效,黄点表示正在连接,绿点表示在线。双击托盘图标打开窗口,可在“本机任务”中查看最近 50 条已接收任务及执行详情。每个任务的输出上传诊断日志保存在 `%ProgramData%\Zcbot\WindowsNode\jobs\\logs\node-output-upload.log`,记录阶段、产物文件名、重试次数和 Windows `HRESULT`,不记录 Node Token 或认证请求头;单文件达到 1 MiB 后轮转一份 `.1`。无界面运行使用: +直接双击 EXE 默认启动托盘 UI;红点表示未注册或身份失效,黄点表示正在连接,绿点表示在线。双击托盘图标打开窗口,可在“本机任务”中查看最近 50 条已接收任务及执行详情。每个任务的输出上传诊断日志保存在 `\jobs\\logs\node-output-upload.log`,记录阶段、产物文件名、重试次数和 Windows `HRESULT`,不记录 Node Token 或认证请求头;单文件达到 1 MiB 后轮转一份 `.1`。无界面运行使用: ```powershell Zcbot.WindowsNode.exe run --headless ``` -注册结果仍保存在 `%ProgramData%\Zcbot\WindowsNode\node.json`,可恢复任务保存在同级 `jobs`,持久工程保存在同级 `workspaces/`;它们不随程序包更新。Workspace 目录不按 user ID 分层,用户归属、home node、head Job 和容量由服务端数据库集中维护。Token 使用 DPAPI `LocalMachine` 加密,目录与文件 ACL 只保留注册时的运行账号和 `SYSTEM`。复制配置到另一台机器不能解密 Token;Windows 重装、运行账号变更或身份丢失时应由管理员禁用旧节点并重新注册。 +注册结果保存在 `\node.json`,可恢复任务保存在同级 `jobs`,持久工程保存在同级 `workspaces/`;它们不随程序包更新。Workspace 目录不按 user ID 分层,用户归属、home node、head Job 和容量由服务端数据库集中维护。Token 使用 DPAPI `LocalMachine` 加密,目录与文件 ACL 只保留注册时的运行账号和 `SYSTEM`。复制配置到另一台机器不能解密 Token;Windows 重装、运行账号变更或身份丢失时应由管理员禁用旧节点并重新注册。 diff --git a/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs b/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs index 8d73ffc..ef101e7 100644 --- a/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs +++ b/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Runtime.InteropServices; using System.Security; using System.Text; @@ -16,6 +17,10 @@ internal sealed class ConfigurationForm : Form private readonly Button reconnect = CreateButton("立即重连", 112, primary: true); private readonly Button resetIdentity = CreateButton("清除本机身份并重新注册", 220); private readonly Button copyDiagnostics = CreateButton("复制诊断信息", 132); + private readonly TextBox dataRoot = CreateTextBox(); + private readonly Button changeDataRoot = CreateButton("更改并迁移", 124, primary: true); + private readonly Button openDataRoot = CreateButton("打开目录", 104); + private readonly Label dataRootStatus = CreateBodyLabel(); private readonly Button runAnsysAcceptance = CreateButton("运行内置基准验收", 180, primary: true); private readonly Button enableAnsysGate = CreateButton("开启 ANSYS 执行门", 164); private readonly CheckBox startAtLogin = new() @@ -55,10 +60,12 @@ internal sealed class ConfigurationForm : Form private NodeConfig? currentConfig; private string? passedAcceptanceReport; private CancellationTokenSource? acceptanceStop; + private bool dataRootMigrationInProgress; internal event Func? RegisterRequested; internal event Action? ReconnectRequested; internal event Action? ResetIdentityRequested; + internal event Func>? DataRootMigrationRequested; internal ConfigurationForm() { @@ -169,6 +176,16 @@ internal sealed class ConfigurationForm : Form var runtimeCard = CreateCard(); runtimeCard.Controls.Add(CreateSectionTitle("运行设置")); runtimeCard.Controls.Add(startAtLogin); + AddField( + runtimeCard, + "数据目录", + dataRoot, + "保存节点身份、任务、Workspace 和受管 runtime;迁移只支持本机固定磁盘。环境变量优先于界面设置。"); + var dataRootActions = CreateActions(); + dataRootActions.Controls.Add(changeDataRoot); + dataRootActions.Controls.Add(openDataRoot); + runtimeCard.Controls.Add(dataRootActions); + runtimeCard.Controls.Add(dataRootStatus); runtimeCard.Controls.Add(CreateHint( "关闭此窗口后,节点仍在系统托盘运行。右键托盘图标可以立即重连或退出。")); page.Controls.Add(runtimeCard); @@ -185,6 +202,8 @@ internal sealed class ConfigurationForm : Form runAnsysAcceptance.Click += async (_, _) => await RunAnsysAcceptanceAsync(); enableAnsysGate.Click += (_, _) => EnableAnsysGate(); startAtLogin.CheckedChanged += (_, _) => ToggleStartup(); + changeDataRoot.Click += async (_, _) => await ChangeDataRootAsync(); + openDataRoot.Click += (_, _) => OpenDataRoot(); FormClosing += (_, eventArgs) => { if (eventArgs.CloseReason == CloseReason.UserClosing) @@ -194,6 +213,8 @@ internal sealed class ConfigurationForm : Form } }; startAtLogin.Checked = StartupRegistration.IsEnabled; + dataRoot.ReadOnly = true; + RefreshDataRoot(); jobGrid.SelectionChanged += (_, _) => ShowSelectedJob(); jobRefreshTimer.Tick += (_, _) => RefreshJobs(); jobRefreshTimer.Start(); @@ -755,6 +776,134 @@ internal sealed class ConfigurationForm : Form return grid; } + private void RefreshDataRoot() + { + var selection = NodeDataRootSettings.Resolve(); + dataRoot.Text = selection.RootDirectory; + changeDataRoot.Enabled = !selection.IsEnvironmentManaged && !dataRootMigrationInProgress; + dataRootStatus.Text = selection.IsEnvironmentManaged + ? $"由机器环境变量 {NodeDataRootSettings.EnvironmentVariableName} 管理,界面不可修改。" + : selection.Source == "user" + ? "使用当前 Windows 专用账号保存的自定义目录。" + : "使用默认目录。"; + } + + private async Task ChangeDataRootAsync() + { + if (DataRootMigrationRequested is null) + { + return; + } + try + { + if (jobInbox.HasPendingJobs) + { + MessageBox.Show( + "本机仍有未完成任务,暂不能迁移数据目录。请等待任务结束后重试。", + "暂不能迁移", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + MessageBox.Show( + $"无法确认本机任务状态,数据目录不会迁移:{exception.Message}", + "暂不能迁移", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + using var dialog = new FolderBrowserDialog + { + Description = "选择新的 zcbot Windows Node 数据目录(必须为空)", + UseDescriptionForTitle = true, + SelectedPath = dataRoot.Text, + ShowNewFolderButton = true, + }; + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + string target; + try + { + target = NodeDataRootSettings.Normalize(dialog.SelectedPath); + } + catch (NodeConfigurationException exception) + { + MessageBox.Show(exception.Message, "目录无效", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + if (string.Equals(target, dataRoot.Text, StringComparison.OrdinalIgnoreCase)) + { + MessageBox.Show("所选目录与当前数据目录相同。", "无需迁移", + MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + var answer = MessageBox.Show( + $"Node 将先停止接收任务,把现有数据复制并校验到:\n{target}\n\n" + + "校验成功后切换目录并重启;旧目录不会自动删除。是否继续?", + "迁移数据目录", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, + MessageBoxDefaultButton.Button2); + if (answer != DialogResult.Yes) + { + return; + } + + try + { + dataRootMigrationInProgress = true; + changeDataRoot.Enabled = false; + dataRootStatus.Text = "正在停止调度并复制、校验数据,请勿退出 Node…"; + var result = await DataRootMigrationRequested(target); + MessageBox.Show( + $"数据目录迁移完成:{result.FileCount} 个文件,{FormatBytes(result.TotalBytes)}。\n" + + $"新目录:{result.TargetDirectory}\n\n旧目录仍保留:{result.SourceDirectory}\n" + + "确认新节点运行正常后,可由管理员手工清理旧目录。Node 现在将重启。", + "迁移完成", MessageBoxButtons.OK, MessageBoxIcon.Information); + Application.Restart(); + } + catch (Exception exception) when ( + exception is IOException + or NodeConfigurationException + or SecurityException + or UnauthorizedAccessException) + { + dataRootMigrationInProgress = false; + RefreshDataRoot(); + MessageBox.Show( + $"数据目录未切换,原目录继续有效:{exception.Message}", + "迁移失败", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void OpenDataRoot() + { + try + { + Directory.CreateDirectory(dataRoot.Text); + var startInfo = new ProcessStartInfo + { + FileName = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Windows), "explorer.exe"), + UseShellExecute = false, + }; + startInfo.ArgumentList.Add(dataRoot.Text); + Process.Start(startInfo); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or InvalidOperationException) + { + MessageBox.Show(exception.Message, "无法打开数据目录", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private static string FormatBytes(long bytes) => bytes >= 1024L * 1024 * 1024 + ? $"{bytes / (1024d * 1024 * 1024):F2} GiB" + : bytes >= 1024L * 1024 + ? $"{bytes / (1024d * 1024):F1} MiB" + : $"{bytes / 1024d:F1} KiB"; + private static Panel CreateDivider() => new() { Height = 1, diff --git a/windows-node/Zcbot.WindowsNode/NodeConfigStore.cs b/windows-node/Zcbot.WindowsNode/NodeConfigStore.cs index 5a7b635..415544c 100644 --- a/windows-node/Zcbot.WindowsNode/NodeConfigStore.cs +++ b/windows-node/Zcbot.WindowsNode/NodeConfigStore.cs @@ -97,7 +97,7 @@ internal sealed class NodeConfigStore(NodePaths paths) } } - private static void RestrictDirectory(string path) + internal static void RestrictDirectory(string path) { var identity = WindowsIdentity.GetCurrent(); var user = identity.User diff --git a/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs b/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs index 8f672a8..82dee3e 100644 --- a/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs +++ b/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs @@ -81,9 +81,9 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? // A manual reconnect replaces this connection loop. Let any accepted job // finish its local pipeline first so the new loop cannot recover the same // worker concurrently and manufacture a conflicting terminal state. - while (!jobPipelines.IsEmpty) + while (!jobPipelines.IsEmpty || !exportPipelines.IsEmpty) { - await Task.WhenAll(jobPipelines.Values.ToArray()); + await Task.WhenAll(jobPipelines.Values.Concat(exportPipelines.Values).ToArray()); } } } @@ -582,7 +582,8 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? private object RuntimePayload() { - var root = Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\"; + var root = Path.GetPathRoot(NodePaths.ForCurrentMachine().RootDirectory) + ?? throw new NodeConfigurationException("Data root has no drive."); var capabilityRuntime = adapters.All .ToDictionary( item => item.Capability, diff --git a/windows-node/Zcbot.WindowsNode/NodeDataRoot.cs b/windows-node/Zcbot.WindowsNode/NodeDataRoot.cs new file mode 100644 index 0000000..247d9bc --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/NodeDataRoot.cs @@ -0,0 +1,288 @@ +using Microsoft.Win32; +using System.Security; +using System.Security.Cryptography; + +namespace Zcbot.WindowsNode; + +internal sealed record NodeDataRootSelection( + string RootDirectory, + bool IsEnvironmentManaged, + string Source); + +internal sealed record NodeDataMigrationResult( + string SourceDirectory, + string TargetDirectory, + int FileCount, + long TotalBytes); + +internal static class NodeDataRootSettings +{ + internal const string EnvironmentVariableName = "ZCBOT_WINDOWS_NODE_DATA_DIR"; + internal const string RegistryPath = @"Software\Zcbot\WindowsNode"; + internal const string RegistryValueName = "DataRoot"; + + internal static NodeDataRootSelection Resolve() + { + var managed = Environment.GetEnvironmentVariable(EnvironmentVariableName)?.Trim(); + if (!string.IsNullOrWhiteSpace(managed)) + { + return new NodeDataRootSelection( + Normalize(managed), IsEnvironmentManaged: true, "environment"); + } + + using var key = Registry.CurrentUser.OpenSubKey(RegistryPath, writable: false); + var configured = (key?.GetValue(RegistryValueName) as string)?.Trim(); + if (!string.IsNullOrWhiteSpace(configured)) + { + return new NodeDataRootSelection( + Normalize(configured), IsEnvironmentManaged: false, "user"); + } + + return new NodeDataRootSelection(DefaultRoot(), IsEnvironmentManaged: false, "default"); + } + + internal static void SaveUserRoot(string root) + { + var normalized = Normalize(root); + using var key = Registry.CurrentUser.CreateSubKey(RegistryPath, writable: true); + key.SetValue(RegistryValueName, normalized, RegistryValueKind.String); + } + + internal static string Normalize(string root) + { + if (string.IsNullOrWhiteSpace(root) || !Path.IsPathFullyQualified(root)) + { + throw new NodeConfigurationException("Data root must be an absolute local path."); + } + var normalized = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root.Trim())); + if (normalized.StartsWith(@"\\", StringComparison.Ordinal)) + { + throw new NodeConfigurationException("Data root must be on a local drive."); + } + if (string.Equals(normalized, Path.GetPathRoot(normalized), StringComparison.OrdinalIgnoreCase)) + { + throw new NodeConfigurationException("Data root cannot be the root of a drive."); + } + return normalized; + } + + private static string DefaultRoot() => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Zcbot", "WindowsNode"); +} + +internal static class NodeDataMigrator +{ + internal static Task MigrateAsync( + string sourceRoot, + string targetRoot, + CancellationToken cancellationToken) => Task.Run( + () => Migrate(sourceRoot, targetRoot, cancellationToken), cancellationToken); + + private static NodeDataMigrationResult Migrate( + string sourceRoot, + string targetRoot, + CancellationToken cancellationToken) + { + var source = NodeDataRootSettings.Normalize(sourceRoot); + var target = NodeDataRootSettings.Normalize(targetRoot); + if (string.Equals(source, target, StringComparison.OrdinalIgnoreCase)) + { + throw new NodeConfigurationException("The new data root is the same as the current data root."); + } + EnsureSeparateTrees(source, target); + var targetDrive = EnsureLocalFixedDrive(target); + + if (Directory.Exists(target) && Directory.EnumerateFileSystemEntries(target).Any()) + { + throw new NodeConfigurationException("The selected data root must be empty."); + } + if (File.Exists(target)) + { + throw new NodeConfigurationException("The selected data root is an existing file."); + } + + var requiredBytes = Directory.Exists(source) + ? EnumerateFiles(source) + .Sum(path => new FileInfo(path).Length) + : 0L; + if (targetDrive.AvailableFreeSpace < requiredBytes) + { + throw new NodeConfigurationException( + $"The target drive needs at least {requiredBytes} free bytes for migration."); + } + + var parent = Directory.GetParent(target)?.FullName + ?? throw new NodeConfigurationException("The selected data root has no parent directory."); + Directory.CreateDirectory(parent); + var staging = Path.Combine(parent, $".{Path.GetFileName(target)}.migration-{Guid.NewGuid():N}"); + Directory.CreateDirectory(staging); + NodeConfigStore.RestrictDirectory(staging); + + try + { + var copied = Directory.Exists(source) + ? CopyTree(source, staging, cancellationToken) + : (FileCount: 0, TotalBytes: 0L); + if (Directory.Exists(source)) + { + VerifyTree(source, staging, cancellationToken); + } + if (Directory.Exists(target)) + { + Directory.Delete(target, recursive: false); + } + Directory.Move(staging, target); + return new NodeDataMigrationResult(source, target, copied.FileCount, copied.TotalBytes); + } + catch + { + try + { + if (Directory.Exists(staging)) + { + Directory.Delete(staging, recursive: true); + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + throw; + } + } + + private static (int FileCount, long TotalBytes) CopyTree( + string source, + string target, + CancellationToken cancellationToken) + { + var fileCount = 0; + long totalBytes = 0; + foreach (var directory in EnumerateDirectories(source)) + { + cancellationToken.ThrowIfCancellationRequested(); + Directory.CreateDirectory(Path.Combine(target, Path.GetRelativePath(source, directory))); + } + foreach (var file in EnumerateFiles(source)) + { + cancellationToken.ThrowIfCancellationRequested(); + var destination = Path.Combine(target, Path.GetRelativePath(source, file)); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(file, destination, overwrite: false); + var length = new FileInfo(file).Length; + fileCount++; + totalBytes += length; + } + return (fileCount, totalBytes); + } + + private static void VerifyTree( + string source, + string target, + CancellationToken cancellationToken) + { + var sourceFiles = EnumerateFiles(source) + .Select(path => Path.GetRelativePath(source, path)) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var targetFiles = EnumerateFiles(target) + .Select(path => Path.GetRelativePath(target, path)) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (!sourceFiles.SequenceEqual(targetFiles, StringComparer.OrdinalIgnoreCase)) + { + throw new IOException("Data root migration verification found a different file set."); + } + foreach (var relativePath in sourceFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + var sourcePath = Path.Combine(source, relativePath); + var targetPath = Path.Combine(target, relativePath); + if (new FileInfo(sourcePath).Length != new FileInfo(targetPath).Length + || !HashFile(sourcePath).SequenceEqual(HashFile(targetPath))) + { + throw new IOException($"Data root migration verification failed: {relativePath}"); + } + } + } + + private static byte[] HashFile(string path) + { + using var stream = new FileStream( + path, FileMode.Open, FileAccess.Read, FileShare.Read, 1024 * 1024, + FileOptions.SequentialScan); + return SHA256.HashData(stream); + } + + private static IEnumerable EnumerateDirectories(string root) + { + var pending = new Stack(); + pending.Push(root); + while (pending.Count > 0) + { + var current = pending.Pop(); + foreach (var directory in Directory.EnumerateDirectories( + current, "*", SearchOption.TopDirectoryOnly)) + { + RejectReparsePoint(directory); + pending.Push(directory); + yield return directory; + } + } + } + + private static IEnumerable EnumerateFiles(string root) + { + RejectReparsePoint(root); + foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.TopDirectoryOnly)) + { + RejectReparsePoint(file); + yield return file; + } + foreach (var directory in EnumerateDirectories(root)) + { + foreach (var file in Directory.EnumerateFiles( + directory, "*", SearchOption.TopDirectoryOnly)) + { + RejectReparsePoint(file); + yield return file; + } + } + } + + private static void EnsureSeparateTrees(string source, string target) + { + var separator = Path.DirectorySeparatorChar.ToString(); + var sourcePrefix = source + separator; + var targetPrefix = target + separator; + if (target.StartsWith(sourcePrefix, StringComparison.OrdinalIgnoreCase) + || source.StartsWith(targetPrefix, StringComparison.OrdinalIgnoreCase)) + { + throw new NodeConfigurationException( + "The old and new data roots cannot contain one another."); + } + } + + private static DriveInfo EnsureLocalFixedDrive(string target) + { + var root = Path.GetPathRoot(target) + ?? throw new NodeConfigurationException("The selected data root has no drive."); + var drive = new DriveInfo(root); + if (!drive.IsReady || drive.DriveType != DriveType.Fixed) + { + throw new NodeConfigurationException("Data root must be on a fixed local drive."); + } + return drive; + } + + private static void RejectReparsePoint(string path) + { + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + { + throw new IOException($"Data root migration does not follow links: {path}"); + } + } +} diff --git a/windows-node/Zcbot.WindowsNode/NodeModels.cs b/windows-node/Zcbot.WindowsNode/NodeModels.cs index abd20dc..ad03cc0 100644 --- a/windows-node/Zcbot.WindowsNode/NodeModels.cs +++ b/windows-node/Zcbot.WindowsNode/NodeModels.cs @@ -42,9 +42,7 @@ internal sealed record NodePaths( { internal static NodePaths ForCurrentMachine() { - var root = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), - "Zcbot", "WindowsNode"); + var root = NodeDataRootSettings.Resolve().RootDirectory; return new NodePaths( root, Path.Combine(root, "node.json"), diff --git a/windows-node/Zcbot.WindowsNode/Program.cs b/windows-node/Zcbot.WindowsNode/Program.cs index 3e91cd8..c27ecb6 100644 --- a/windows-node/Zcbot.WindowsNode/Program.cs +++ b/windows-node/Zcbot.WindowsNode/Program.cs @@ -25,9 +25,21 @@ internal static class Program return 0; } - var store = new NodeConfigStore(NodePaths.ForCurrentMachine()); - Application.Run(new TrayApplicationContext(store)); - return 0; + try + { + var store = new NodeConfigStore(NodePaths.ForCurrentMachine()); + Application.Run(new TrayApplicationContext(store)); + return 0; + } + catch (NodeConfigurationException exception) + { + MessageBox.Show( + exception.Message, + "zcbot Windows Node 配置无效", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + return 2; + } } private static async Task RunCommandAsync(string[] args) diff --git a/windows-node/Zcbot.WindowsNode/TrayApplicationContext.cs b/windows-node/Zcbot.WindowsNode/TrayApplicationContext.cs index 124a3e6..c23a473 100644 --- a/windows-node/Zcbot.WindowsNode/TrayApplicationContext.cs +++ b/windows-node/Zcbot.WindowsNode/TrayApplicationContext.cs @@ -22,6 +22,7 @@ internal sealed class TrayApplicationContext : ApplicationContext form.RegisterRequested += RegisterAsync; form.ReconnectRequested += RestartConnection; form.ResetIdentityRequested += ResetIdentity; + form.DataRootMigrationRequested += MigrateDataRootAsync; statusItem = new ToolStripMenuItem("尚未注册") { Enabled = false }; var menu = new ContextMenuStrip(); @@ -153,6 +154,28 @@ internal sealed class TrayApplicationContext : ApplicationContext StartConnection(); } + private async Task MigrateDataRootAsync(string targetRoot) + { + var sourceRoot = NodePaths.ForCurrentMachine().RootDirectory; + connectionStop?.Cancel(); + try + { + if (connectionTask is not null) + { + await connectionTask; + } + var result = await NodeDataMigrator.MigrateAsync( + sourceRoot, targetRoot, CancellationToken.None); + NodeDataRootSettings.SaveUserRoot(result.TargetDirectory); + return result; + } + catch + { + StartConnection(); + throw; + } + } + private void PostStatus(NodeStatus status) { if (form.IsHandleCreated) diff --git a/windows-node/install-windows-node.bat b/windows-node/install-windows-node.bat index f8d84d0..0fa2975 100644 --- a/windows-node/install-windows-node.bat +++ b/windows-node/install-windows-node.bat @@ -3,18 +3,24 @@ setlocal EnableExtensions cd /d "%~dp0" set "NODE_EXE=%~dp0Zcbot.WindowsNode.exe" +set "NODE_DATA_ROOT=%ZCBOT_WINDOWS_NODE_DATA_DIR%" +if not defined NODE_DATA_ROOT ( + for /f "tokens=2,*" %%A in ('reg.exe query "HKCU\Software\Zcbot\WindowsNode" /v DataRoot 2^>nul ^| findstr.exe /I /C:"DataRoot"') do set "NODE_DATA_ROOT=%%B" +) +if not defined NODE_DATA_ROOT set "NODE_DATA_ROOT=%ProgramData%\Zcbot\WindowsNode" set "ORIGIN_REQUIREMENTS=%~dp0adapters\origin.plot@v2\requirements.txt" -set "ORIGIN_RUNTIME_DIR=%ProgramData%\Zcbot\WindowsNode\runtimes\origin" +set "ORIGIN_RUNTIME_DIR=%NODE_DATA_ROOT%\runtimes\origin" set "ORIGIN_RUNTIME_PYTHON=%ORIGIN_RUNTIME_DIR%\Scripts\python.exe" set "ANSYS_REQUIREMENTS=%~dp0adapters\ansys.mechanical.static_structural@v2\requirements.txt" -set "ANSYS_RUNTIME_DIR=%ProgramData%\Zcbot\WindowsNode\runtimes\ansys" +set "ANSYS_RUNTIME_DIR=%NODE_DATA_ROOT%\runtimes\ansys" set "ANSYS_RUNTIME_PYTHON=%ANSYS_RUNTIME_DIR%\Scripts\python.exe" set "BLENDER_REQUIREMENTS=%~dp0adapters\blender.scene.author@v1\requirements.txt" -set "BLENDER_RUNTIME_DIR=%ProgramData%\Zcbot\WindowsNode\runtimes\blender" +set "BLENDER_RUNTIME_DIR=%NODE_DATA_ROOT%\runtimes\blender" set "BLENDER_RUNTIME_PYTHON=%BLENDER_RUNTIME_DIR%\Scripts\python.exe" if not defined ZCBOT_PIP_INDEX_URL set "ZCBOT_PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/" echo [INFO] Installing zcbot Windows Node... +echo [INFO] Data root: "%NODE_DATA_ROOT%" if not exist "%NODE_EXE%" ( echo [ERR] Zcbot.WindowsNode.exe was not found beside this installer.