From 38deea7d1d71d3bf043f78f6e7c8427889073c56 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 31 Aug 2026 17:22:11 +0800 Subject: [PATCH] feat(windows-node): complete WPF host migration --- CHANGELOG.md | 2 + DESIGN.md | 2 + PROGRESS.md | 6 +- RUN.md | 2 +- tests/test_windows_node_source.py | 593 ++++--- windows-node/README.md | 4 +- windows-node/WPF_HOST_REFACTOR.md | 774 ++++++++++ .../AdapterArchitectureTests.cs | 89 ++ .../AnsysAcceptanceViewModelTests.cs | 109 ++ .../DataRootPageViewModelTests.cs | 85 + .../Zcbot.WindowsNode.Tests/GlobalUsings.cs | 2 + .../JobOfferPersistenceTests.cs | 118 ++ .../JobPageViewModelTests.cs | 88 ++ .../JobRecoveryServiceTests.cs | 67 + .../JobRepositoryCompatibilityTests.cs | 109 ++ .../JobStateMachineTests.cs | 65 + .../NodeApplicationControllerTests.cs | 174 +++ .../NodeProtocolClientTests.cs | 178 +++ .../NodeProtocolCodecTests.cs | 44 + .../NodeSessionTests.cs | 108 ++ .../PersistenceSafetyTests.cs | 40 + .../PresentationCommandTests.cs | 379 +++++ .../SoftwarePageViewModelTests.cs | 145 ++ .../TransferRetryPolicyTests.cs | 111 ++ .../WorkspaceCompatibilityTests.cs | 83 + .../Zcbot.WindowsNode.Tests.csproj | 23 + .../packages.lock.json | 137 ++ windows-node/Zcbot.WindowsNode.slnx | 1 + .../Adapters/AdapterCatalog.cs | 59 + .../Adapters/AdapterContractLoader.cs | 185 +++ .../AdapterExecutionService.cs} | 68 +- .../Adapters/JobExecutionGate.cs | 31 + .../Adapters/ProcessSupervisor.cs | 49 + .../Application/AnsysAcceptanceService.cs | 76 + .../Application/DataRootManagementService.cs | 19 + .../Application/DiagnosticService.cs | 72 + .../Application/JobMonitorService.cs | 189 +++ .../Application/NodeApplicationController.cs | 280 ++++ .../Zcbot.WindowsNode/Assets/zcbot.ico | Bin 0 -> 8166 bytes .../Bootstrap/NodeCompositionRoot.cs | 28 + .../Zcbot.WindowsNode/ConfigurationForm.cs | 1372 ----------------- .../Zcbot.WindowsNode/EnrollmentClient.cs | 6 +- .../Zcbot.WindowsNode/GlobalUsings.cs | 2 + .../Host/NodeConnectionLoop.cs | 43 + .../Zcbot.WindowsNode/Host/NodeSession.cs | 134 ++ .../Zcbot.WindowsNode/JobInboxStore.cs | 362 +---- .../Zcbot.WindowsNode/JobInputDownloader.cs | 13 +- .../Zcbot.WindowsNode/JobOutputUploader.cs | 53 +- .../JobCoordinator.cs} | 433 ++---- .../Jobs/JobRecoveryService.cs | 38 + .../Zcbot.WindowsNode/Jobs/JobRepository.cs | 161 ++ .../Zcbot.WindowsNode/Jobs/JobStateMachine.cs | 77 + .../Zcbot.WindowsNode/NodeAdapters.cs | 76 +- .../Zcbot.WindowsNode/NodeConfigStore.cs | 21 +- .../Zcbot.WindowsNode/NodeDataRoot.cs | 1 + .../Persistence/AtomicFile.cs | 32 + .../Persistence/PathGuard.cs | 20 + .../Zcbot.WindowsNode/Presentation/App.cs | 47 + .../Infrastructure/AsyncCommand.cs | 54 + .../Infrastructure/ObservableObject.cs | 26 + .../Infrastructure/RelayCommand.cs | 14 + .../Presentation/MainWindow.xaml | 453 ++++++ .../Presentation/MainWindow.xaml.cs | 83 + .../Services/SoftwareManagementService.cs | 146 ++ .../Presentation/Themes/LightTheme.xaml | 238 +++ .../Presentation/Tray/TrayHost.cs | 359 +++++ .../ViewModels/AnsysAcceptanceViewModel.cs | 169 ++ .../ViewModels/DataRootPageViewModel.cs | 132 ++ .../ViewModels/JobPageViewModel.cs | 120 ++ .../ViewModels/MainWindowViewModel.cs | 390 +++++ .../ViewModels/SoftwarePageViewModel.cs | 274 ++++ windows-node/Zcbot.WindowsNode/Program.cs | 14 +- .../Properties/AssemblyInfo.cs | 3 + .../Protocol/NodeProtocolClient.cs | 128 ++ .../Protocol/NodeProtocolCodec.cs | 34 + .../Runtime/ManagedRuntimeInstaller.cs | 215 +++ .../Runtime/SoftwareCatalog.cs | 35 + .../SoftwareLocationService.cs} | 268 +--- .../Runtime/SoftwareProbeService.cs | 42 + .../Runtime/SoftwareRuntimeModels.cs | 25 + .../Zcbot.WindowsNode/StartupRegistration.cs | 13 + .../Transfers/NodeHttpClient.cs | 79 + .../Transfers/TransferRetryPolicy.cs | 60 + .../TrayApplicationContext.cs | 290 ---- .../Zcbot.WindowsNode/TrayIconFactory.cs | 29 +- .../Zcbot.WindowsNode.csproj | 3 + .../Zcbot.WindowsNode/packages.lock.json | 2 +- windows-node/tools/generate-zcbot-icon.ps1 | 116 ++ 88 files changed, 8386 insertions(+), 2913 deletions(-) create mode 100644 windows-node/WPF_HOST_REFACTOR.md create mode 100644 windows-node/Zcbot.WindowsNode.Tests/AdapterArchitectureTests.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/AnsysAcceptanceViewModelTests.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/DataRootPageViewModelTests.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/GlobalUsings.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/JobOfferPersistenceTests.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/JobPageViewModelTests.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/JobRecoveryServiceTests.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/JobRepositoryCompatibilityTests.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/JobStateMachineTests.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/NodeApplicationControllerTests.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/NodeProtocolClientTests.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/NodeProtocolCodecTests.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/NodeSessionTests.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/PersistenceSafetyTests.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/PresentationCommandTests.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/SoftwarePageViewModelTests.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/TransferRetryPolicyTests.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/WorkspaceCompatibilityTests.cs create mode 100644 windows-node/Zcbot.WindowsNode.Tests/Zcbot.WindowsNode.Tests.csproj create mode 100644 windows-node/Zcbot.WindowsNode.Tests/packages.lock.json create mode 100644 windows-node/Zcbot.WindowsNode/Adapters/AdapterCatalog.cs create mode 100644 windows-node/Zcbot.WindowsNode/Adapters/AdapterContractLoader.cs rename windows-node/Zcbot.WindowsNode/{AdapterProcessRunner.cs => Adapters/AdapterExecutionService.cs} (88%) create mode 100644 windows-node/Zcbot.WindowsNode/Adapters/JobExecutionGate.cs create mode 100644 windows-node/Zcbot.WindowsNode/Adapters/ProcessSupervisor.cs create mode 100644 windows-node/Zcbot.WindowsNode/Application/AnsysAcceptanceService.cs create mode 100644 windows-node/Zcbot.WindowsNode/Application/DataRootManagementService.cs create mode 100644 windows-node/Zcbot.WindowsNode/Application/DiagnosticService.cs create mode 100644 windows-node/Zcbot.WindowsNode/Application/JobMonitorService.cs create mode 100644 windows-node/Zcbot.WindowsNode/Application/NodeApplicationController.cs create mode 100644 windows-node/Zcbot.WindowsNode/Assets/zcbot.ico create mode 100644 windows-node/Zcbot.WindowsNode/Bootstrap/NodeCompositionRoot.cs delete mode 100644 windows-node/Zcbot.WindowsNode/ConfigurationForm.cs create mode 100644 windows-node/Zcbot.WindowsNode/GlobalUsings.cs create mode 100644 windows-node/Zcbot.WindowsNode/Host/NodeConnectionLoop.cs create mode 100644 windows-node/Zcbot.WindowsNode/Host/NodeSession.cs rename windows-node/Zcbot.WindowsNode/{NodeConnectionLoop.cs => Jobs/JobCoordinator.cs} (51%) create mode 100644 windows-node/Zcbot.WindowsNode/Jobs/JobRecoveryService.cs create mode 100644 windows-node/Zcbot.WindowsNode/Jobs/JobRepository.cs create mode 100644 windows-node/Zcbot.WindowsNode/Jobs/JobStateMachine.cs create mode 100644 windows-node/Zcbot.WindowsNode/Persistence/AtomicFile.cs create mode 100644 windows-node/Zcbot.WindowsNode/Persistence/PathGuard.cs create mode 100644 windows-node/Zcbot.WindowsNode/Presentation/App.cs create mode 100644 windows-node/Zcbot.WindowsNode/Presentation/Infrastructure/AsyncCommand.cs create mode 100644 windows-node/Zcbot.WindowsNode/Presentation/Infrastructure/ObservableObject.cs create mode 100644 windows-node/Zcbot.WindowsNode/Presentation/Infrastructure/RelayCommand.cs create mode 100644 windows-node/Zcbot.WindowsNode/Presentation/MainWindow.xaml create mode 100644 windows-node/Zcbot.WindowsNode/Presentation/MainWindow.xaml.cs create mode 100644 windows-node/Zcbot.WindowsNode/Presentation/Services/SoftwareManagementService.cs create mode 100644 windows-node/Zcbot.WindowsNode/Presentation/Themes/LightTheme.xaml create mode 100644 windows-node/Zcbot.WindowsNode/Presentation/Tray/TrayHost.cs create mode 100644 windows-node/Zcbot.WindowsNode/Presentation/ViewModels/AnsysAcceptanceViewModel.cs create mode 100644 windows-node/Zcbot.WindowsNode/Presentation/ViewModels/DataRootPageViewModel.cs create mode 100644 windows-node/Zcbot.WindowsNode/Presentation/ViewModels/JobPageViewModel.cs create mode 100644 windows-node/Zcbot.WindowsNode/Presentation/ViewModels/MainWindowViewModel.cs create mode 100644 windows-node/Zcbot.WindowsNode/Presentation/ViewModels/SoftwarePageViewModel.cs create mode 100644 windows-node/Zcbot.WindowsNode/Properties/AssemblyInfo.cs create mode 100644 windows-node/Zcbot.WindowsNode/Protocol/NodeProtocolClient.cs create mode 100644 windows-node/Zcbot.WindowsNode/Protocol/NodeProtocolCodec.cs create mode 100644 windows-node/Zcbot.WindowsNode/Runtime/ManagedRuntimeInstaller.cs create mode 100644 windows-node/Zcbot.WindowsNode/Runtime/SoftwareCatalog.cs rename windows-node/Zcbot.WindowsNode/{SoftwareRuntimeManager.cs => Runtime/SoftwareLocationService.cs} (58%) create mode 100644 windows-node/Zcbot.WindowsNode/Runtime/SoftwareProbeService.cs create mode 100644 windows-node/Zcbot.WindowsNode/Runtime/SoftwareRuntimeModels.cs create mode 100644 windows-node/Zcbot.WindowsNode/Transfers/NodeHttpClient.cs create mode 100644 windows-node/Zcbot.WindowsNode/Transfers/TransferRetryPolicy.cs delete mode 100644 windows-node/Zcbot.WindowsNode/TrayApplicationContext.cs create mode 100644 windows-node/tools/generate-zcbot-icon.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index e3332f2..8c63038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - DeepSeek Flash 的思考过程恢复实时显示,不再等到正文开始后才集中出现。 +- Windows 专业软件节点启用完整的新管理界面,节点状态、首次注册、软件环境、本机任务、ANSYS 验收和数据目录迁移均可直接管理;应用图标、导航、状态提示和窄窗口布局也更清晰,关闭窗口后仍会继续在托盘运行。 + - 国内模型成本统计跟随最新公开价格更新,DeepSeek 会按实际峰谷时段和缓存命中量计算,历史调价后的记录也可审计矫正。 - GLM-5.3 Flash 替换旧版 GLM 并向默认档位开放;上传图片会直接交给主模型理解,长工具任务可延续既有分析状态。 diff --git a/DESIGN.md b/DESIGN.md index 74bed9a..12a5436 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -478,6 +478,8 @@ Core 在 Job offer 中附带由 capability 合同生成的 `request_summary`, 扩展现有 capability 的 feature 时修改共享契约、对应 Worker 和测试,不修改 Core 调度、Job 生命周期或 Node EXE;增加新专业软件时新增契约与一个 adapter 目录。Cloud 会在下一次契约查询时获得新校验和工具 schema;Node 在启动时从本地目录获得能力、版本和入口,并在首次连接及每次心跳中上报全部已发现 adapter。服务端用本次上报中自己具有共享契约的 capability 原子替换节点能力列表,未知能力不进入调度;管理端只读展示该结果,不保存人工能力选择。现有节点因此不需要清身份或重新注册,升级 Node 后重启一次即可在下一次心跳同步。当前选择明确的运维边界:zcbot 契约可热更新,不要求重启;本机 adapter 更新时先退出托盘 Node,整体替换 adapter 目录后重新启动,不重装、不替换 Node EXE,也不建设自动更新平台。当前 Node 仍按整机单执行槽保守串行,未来只有真实并行软件需求出现时,才把 slot 账本升级为 per-capability 租约,而不改变 Job 协议。 +桌面宿主采用 WPF `Application` 持有唯一 GUI 消息循环,WPF 主窗口只通过 ViewModel/Command 消费应用服务与控制器状态;WinForms 被限制在 `NotifyIcon`、文件/目录选择、确认框和剪贴板互操作,不再存在第二套配置窗口或 Host 生命周期。托盘宿主负责把后台状态经 Dispatcher 投递到 WPF、控制窗口显隐,并复用应用控制器完成注册、重连、清身份、数据目录迁移与安全退出。CLI 参数在 WPF 初始化前分流,因此 `run --headless` 不创建窗口或托盘。节点概览、首次注册、登录自启动、专业软件/runtime、本机任务、ANSYS 固定验收和数据目录迁移均已进入 WPF;`SoftwareManagementService`、`JobMonitorService`、`AnsysAcceptanceService`、`DataRootManagementService` 与 `DiagnosticService` 分别封装固定业务边界,ViewModel 不解析 Job 落盘格式、不访问注册表、不创建 Worker,也不直接持有应用控制器。 + Node 通过 `Authorization: Bearer` 与 `X-Node-Id` 建立 `/v1/software-nodes/connect` WebSocket。进程内 Connection Manager 保证同一节点单活,新连接关闭旧连接;`hello`/`heartbeat` 更新版本、容量、软件健康与最后在线时间。管理员禁用节点时先持久化禁用态,再关闭现有连接;断线收尾不得覆盖禁用态。当前单活只覆盖单 Web 进程,生产启用多实例前必须增加 Redis/PG fencing 或将 Node API 固定路由到单一控制面实例。 第二阶段已增加 `software_jobs`(专业软件任务)账本与 `origin.plot@v2` 的 offer/accept 骨架。用户只能在本人 task 下以幂等键提交固定 schema;云端规范化请求并记录 SHA-256,按当前进程真实在线、能力匹配、健康且有空闲 slot 的 Node 创建短期 offer。Node 再次校验 schema、图形类型和输出格式,使用 write-through、flush 与原子 rename 先落本机任务目录,再回 `job_accept`;重复 job 只有 digest 一致才接受。过期或发送失败的 offer 回到队列,lease、Node 和 digest 不匹配的响应被拒绝。Node 接收后云端进入 `dispatched` 而非 `running`,并将 slot 降为 0;只有固定 Worker 真正启动后才进入软件无关的 `software_running`,具体软件和操作由 capability/request 表达。 diff --git a/PROGRESS.md b/PROGRESS.md index 30920a9..25193e7 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-30(国内模型版本化价格、DeepSeek 峰谷计费与历史重算;未发版) +最后更新:2026-08-31(Windows Node Host/WPF 重构阶段 0–7;未发版) --- @@ -20,6 +20,10 @@ --- ## 已完成关键能力 +- **08-31 / Unreleased / Windows Node 完整 WPF 管理界面**:本机任务、ANSYS 固定验收、诊断复制和数据目录迁移全部进入 WPF;任务页每秒刷新本地持久化记录并保持选择,验收支持进度、停止、通过报告校验与机器级执行门,数据迁移继续经过活动任务检查、停连、复制、SHA-256 校验、失败回滚和重启。新增 `JobMonitorService`、`AnsysAcceptanceService`、`DataRootManagementService` 与 `DiagnosticService`,删除经典 `ConfigurationForm` 和旧 `TrayApplicationContext`,WinForms 只保留托盘及受控系统对话框互操作;117 项 .NET 行为测试、28 项 Windows Node 源码专项、格式与 diff 检查通过,未启动专业软件、连接生产服务或读写真实节点数据。 + +- **08-31 / Unreleased / Windows Node Host/WPF 重构阶段 0–6 第二切片**:WPF 在节点概览、首次注册和运行设置基础上新增专业软件页,Origin、ANSYS、Blender 卡片通过 `SoftwareManagementService` 统一获取位置、runtime 和 probe 快照,可选择或恢复自动检测位置、安装/更新独立 Python 3.12 环境并取消安装。选择对话框仍限制在 Tray WinForms 互操作层,ViewModel 不访问注册表或启动进程;全机执行门会禁用安装,`ManagedRuntimeInstaller` 入口继续二次拒绝活动任务并保持临时验证、原子替换和失败 rollback。同步完成 WPF 视觉打磨:主题 token、圆角控件模板、导航选中态、状态色带/徽标、软件可用性标识、忙碌进度和窄窗口操作换行统一落地。ANSYS 验收、本机任务和数据目录迁移仍从经典配置进入。73 项 .NET 行为测试、191 项 Origin/ANSYS/Blender/合同/Node/UI 专项、格式与 diff 检查、Release 发布包及隔离 GUI/headless 烟测通过,未安装 runtime、启动专业软件、连接生产服务或读写真实节点数据。 + - **08-30 / Unreleased / 国内模型版本化计费与历史矫正工具**:新增本地价格目录解析,直连 DeepSeek、豆包、智谱不再被 LiteLLM 滞后价目覆盖;DeepSeek 按 2026-08-16 官方新价及工作日 UTC 峰谷窗口计费,豆包使用人民币固定价,GLM 绑定国内 BigModel 标准按量价。主对话、Prompt 润色、自动标题、上下文折叠和知识库摘要统一提取缓存 usage 并保存价格 revision/tier/币种/汇率/分项快照;新增默认 dry-run、显式确认 apply/rollback 的历史重算脚本,可针对调价后 DeepSeek 事件审计并矫正成本。无 schema/migration,未连接或写入生产数据库。 - **08-27 / Unreleased / GLM-5.3 Flash + 通用 reasoning/多模态能力**:下架 GLM-5.1/5.2 的可选入口并以隐藏别名将存量 `glm.pro/pro52` 统一解析到 `glm.flash53`,Flash53 加入默认与专业档位;模型能力新增原生输入模态和 `none/tool_turn/conversation/provider_managed` reasoning 生命周期,历史状态仅向同一生产模型回放,DeepSeek 限当前工具轮、GLM 保留同模型会话并发送 `clear_thinking=false`。结构化图片附件仍以文件引用为事实源,只在 provider 请求边界安全物化为 Base64 `image_url`,GLM 不再注册 `look_at_image`,文档读取与生图/视频工具保持独立;无 schema/migration/依赖变化,未调用真实模型或连接生产数据库。 diff --git a/RUN.md b/RUN.md index b894525..ee441b3 100644 --- a/RUN.md +++ b/RUN.md @@ -1178,7 +1178,7 @@ Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打 注册配置写入 `\node.json`;Token 使用 DPAPI `LocalMachine` 加密,ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号配置、注册并运行 Node。当前 MVP 可由 UI 写入该账号的登录启动项,不安装 Windows Service。 -直接双击 EXE 启动托盘 UI:红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开配置窗。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless`。 +直接双击 EXE 启动托盘 UI:红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开 WPF 主窗口,可完成首次注册、重连、清除身份、复制诊断信息和登录自启动设置。“专业软件”页可配置 Origin、ANSYS、Blender 位置及各自 runtime,并运行或停止 ANSYS 固定验收、在报告通过后开启机器级执行门;“本机任务”页每秒刷新本地持久化记录;“运行设置”页可打开或安全迁移完整数据目录。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless`。 若执行中的 Job 遇到 Node 重连,客户端会先等待已接收的本地执行管线收尾,再建立新连接,避免同一 Worker 被新旧连接同时恢复。若云端已将 Job 判为失败或取消、但本地 Worker 随后仍生成了结果,本机任务会显示“云端已终止”并保留工作区文件,不再永久停在 90% 重传;这些本地结果不会反向覆盖云端终态。 diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py index 9201889..dbfb36d 100644 --- a/tests/test_windows_node_source.py +++ b/tests/test_windows_node_source.py @@ -9,17 +9,28 @@ PROJECT = ROOT / "Zcbot.WindowsNode" class WindowsNodeSourceTests(unittest.TestCase): - def test_project_targets_net10_windows_forms_with_json_schema_validator(self) -> None: + def test_project_targets_net10_wpf_with_windows_forms_interop(self) -> None: tree = ET.parse(PROJECT / "Zcbot.WindowsNode.csproj") root = tree.getroot() self.assertEqual(root.findtext("./PropertyGroup/TargetFramework"), "net10.0-windows") self.assertEqual(root.findtext("./PropertyGroup/UseWindowsForms"), "true") + self.assertEqual(root.findtext("./PropertyGroup/UseWPF"), "true") self.assertEqual(root.findtext("./PropertyGroup/OutputType"), "WinExe") + self.assertEqual( + root.findtext("./PropertyGroup/ApplicationIcon"), "Assets\\zcbot.ico" + ) + self.assertEqual( + [item.attrib["Include"] for item in root.findall("./ItemGroup/Resource")], + ["Assets\\zcbot.ico"], + ) + icon = PROJECT / "Assets" / "zcbot.ico" + self.assertTrue(icon.is_file()) + self.assertEqual(icon.read_bytes()[:6], b"\x00\x00\x01\x00\x07\x00") packages = root.findall("./ItemGroup/PackageReference") self.assertEqual([item.attrib["Include"] for item in packages], ["JsonSchema.Net"]) def test_node_protocol_and_secret_storage_markers_are_present(self) -> None: - source = "\n".join(path.read_text(encoding="utf-8") for path in PROJECT.glob("*.cs")) + source = "\n".join(path.read_text(encoding="utf-8") for path in PROJECT.rglob("*.cs")) for marker in ( "v1/software-nodes/enroll", "v1/software-nodes/connect", @@ -29,16 +40,129 @@ class WindowsNodeSourceTests(unittest.TestCase): "SetAccessRuleProtection(isProtected: true", '"adapter.json"', "NotifyIcon", - "ConfigurationForm", + "MainWindow", "TrayIconFactory.Create", '"--headless"', ): self.assertIn(marker, source) + def test_gui_and_headless_share_the_composition_root(self) -> None: + program = (PROJECT / "Program.cs").read_text(encoding="utf-8") + composition = ( + PROJECT / "Bootstrap" / "NodeCompositionRoot.cs" + ).read_text(encoding="utf-8") + controller = ( + PROJECT / "Application" / "NodeApplicationController.cs" + ).read_text(encoding="utf-8") + + self.assertEqual(program.count("new NodeCompositionRoot"), 2) + self.assertIn("CreateApplicationController()", program) + self.assertIn("RunHeadlessAsync(shutdown.Token)", program) + self.assertIn("new App(compositionRoot.CreateApplicationController())", program) + self.assertIn("return app.Run()", program) + self.assertNotIn("new TrayApplicationContext", program) + self.assertIn("new NodeConnectionLoop(config, Paths", composition) + self.assertIn("stopTask ??= StopOnceAsync(mode)", controller) + self.assertIn("await StopConnectionAsync()", controller) + + def test_wpf_overview_registration_and_settings_use_view_model_commands(self) -> None: + xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text( + encoding="utf-8" + ) + view_model = ( + PROJECT / "Presentation" / "ViewModels" / "MainWindowViewModel.cs" + ).read_text(encoding="utf-8") + tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text( + encoding="utf-8" + ) + startup = (PROJECT / "StartupRegistration.cs").read_text(encoding="utf-8") + + for binding in ( + "ShowOverviewCommand", + "ShowSettingsCommand", + "RegisterCommand", + "ReconnectCommand", + "ResetIdentityCommand", + "IsStartupEnabled", + "DataRootPage.CurrentRoot", + ): + self.assertIn(f"{{Binding {binding}", xaml) + self.assertIn('PasswordChanged="EnrollmentCodeBox_OnPasswordChanged"', xaml) + self.assertIn("CopyDiagnosticsCommand", xaml) + self.assertNotIn("OpenLegacyCommand", xaml + view_model) + self.assertNotIn("NodeApplicationController", xaml + view_model) + self.assertIn("interface IStartupRegistration", startup) + self.assertIn("RegisterRequested += RegisterFromWpfAsync", tray) + self.assertIn("ReconnectRequested += ReconnectFromWpfAsync", tray) + self.assertIn("ResetIdentityRequested += ResetIdentityFromWpfAsync", tray) + + def test_wpf_software_page_uses_runtime_services_and_tray_dialogs(self) -> None: + xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text( + encoding="utf-8" + ) + page = ( + PROJECT / "Presentation" / "ViewModels" / "SoftwarePageViewModel.cs" + ).read_text(encoding="utf-8") + service = ( + PROJECT / "Presentation" / "Services" / "SoftwareManagementService.cs" + ).read_text(encoding="utf-8") + tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text( + encoding="utf-8" + ) + + for binding in ( + "ShowSoftwareCommand", + "SoftwarePage.RefreshCommand", + "SoftwarePage.CancelInstallCommand", + "SoftwarePage.Cards", + "ChooseLocationCommand", + "ClearLocationCommand", + "InstallRuntimeCommand", + ): + self.assertIn(f"{{Binding {binding}", xaml) + self.assertIn("ISoftwareManagementService", page + service) + self.assertIn("SoftwareLocationService.SaveConfiguredPath", service) + self.assertIn("SoftwareLocationService.ClearConfiguredPath", service) + self.assertIn("ManagedRuntimeInstaller.InstallAsync", service) + self.assertIn("AdapterCatalog.HasActiveExecution", service) + self.assertIn("ChooseLocationRequested += ChooseSoftwareLocation", tray) + self.assertIn("ConfirmInstallRequested += ConfirmRuntimeInstall", tray) + self.assertNotIn("OpenFileDialog", page + service) + + def test_wpf_theme_has_selected_navigation_status_and_responsive_actions(self) -> None: + xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text( + encoding="utf-8" + ) + theme = ( + PROJECT / "Presentation" / "Themes" / "LightTheme.xaml" + ).read_text(encoding="utf-8") + + self.assertGreaterEqual(xaml.count("', theme) + self.assertIn('', theme) + def test_workspace_storage_is_global_by_workspace_not_user_directory(self) -> None: models = (PROJECT / "NodeModels.cs").read_text(encoding="utf-8") store = (PROJECT / "WorkspaceStore.cs").read_text(encoding="utf-8") - runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8") + runner = (PROJECT / "Adapters" / "AdapterExecutionService.cs").read_text( + encoding="utf-8" + ) self.assertIn('Path.Combine(root, "workspaces")', models) self.assertIn('Binding(job).WorkspaceId.ToString("D")', store) self.assertNotIn("UserId", store) @@ -48,7 +172,7 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertIn("workspaceStore.Restore", runner) def test_runtime_auto_reports_all_discovered_adapter_capabilities(self) -> None: - connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8") + connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8") config_store = (PROJECT / "NodeConfigStore.cs").read_text(encoding="utf-8") self.assertIn( "var installedCapabilities = adapters.All.Select(item => item.Capability).ToArray()", @@ -62,27 +186,42 @@ class WindowsNodeSourceTests(unittest.TestCase): def test_node_does_not_expose_arbitrary_execution_primitives(self) -> None: source = "\n".join( path.read_text(encoding="utf-8") - for path in PROJECT.glob("*.cs") + for path in PROJECT.rglob("*.cs") if path.name not in { - "AdapterProcessRunner.cs", + "AdapterExecutionService.cs", + "ProcessSupervisor.cs", "ConfigurationForm.cs", - "SoftwareRuntimeManager.cs", + "TrayHost.cs", + "SoftwareLocationService.cs", + "ManagedRuntimeInstaller.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) + tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text( + encoding="utf-8" + ) + self.assertIn('"explorer.exe"', tray) + self.assertIn("startInfo.ArgumentList.Add(path)", tray) + self.assertNotIn("UseShellExecute = true", tray) 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") + page = ( + PROJECT / "Presentation" / "ViewModels" / "DataRootPageViewModel.cs" + ).read_text(encoding="utf-8") + tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text( + encoding="utf-8" + ) + connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8") + controller = ( + PROJECT / "Application" / "NodeApplicationController.cs" + ).read_text(encoding="utf-8") + composition = ( + PROJECT / "Bootstrap" / "NodeCompositionRoot.cs" + ).read_text(encoding="utf-8") self.assertIn('"ZCBOT_WINDOWS_NODE_DATA_DIR"', settings) self.assertIn("Environment.SpecialFolder.LocalApplicationData", settings) @@ -99,15 +238,13 @@ class WindowsNodeSourceTests(unittest.TestCase): 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("MigrationRequested", page) + self.assertIn("service.HasPendingJobs", page) + self.assertIn("saveDataRoot(result.TargetDirectory)", controller) + self.assertIn("NodeDataRootSettings.SaveUserRoot", composition) + self.assertIn("controller.MigrateDataRootAsync(target)", tray) self.assertIn("jobPipelines.Values.Concat(exportPipelines.Values)", connection) - self.assertIn( - "Path.GetPathRoot(NodePaths.ForCurrentMachine().RootDirectory)", connection - ) + self.assertIn("Path.GetPathRoot(paths.RootDirectory)", connection) def test_config_field_names_do_not_serialize_plain_node_token(self) -> None: models = (PROJECT / "NodeModels.cs").read_text(encoding="utf-8") @@ -116,28 +253,46 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertNotIn("string NodeToken", stored_record) def test_configuration_ui_never_displays_or_copies_token(self) -> None: - form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8") - self.assertIn("CreateTextBox(usePassword: true)", form) - self.assertIn("UseSystemPasswordChar = usePassword", form) - self.assertNotIn("NodeToken", form) - self.assertIn("Clipboard.SetText(BuildDiagnosticText(currentConfig))", form) - diagnostics = form.split("private string BuildDiagnosticText", 1)[1].split( - "private async Task RunAnsysAcceptanceAsync", 1 - )[0] + xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text( + encoding="utf-8" + ) + view_model = ( + PROJECT / "Presentation" / "ViewModels" / "MainWindowViewModel.cs" + ).read_text(encoding="utf-8") + diagnostic_service = ( + PROJECT / "Application" / "DiagnosticService.cs" + ).read_text(encoding="utf-8") + tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text( + encoding="utf-8" + ) + self.assertIn(" None: - form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8") + xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text( + encoding="utf-8" + ) + view_model = ( + PROJECT / "Presentation" / "ViewModels" / "AnsysAcceptanceViewModel.cs" + ).read_text(encoding="utf-8") + service = ( + PROJECT / "Application" / "AnsysAcceptanceService.cs" + ).read_text(encoding="utf-8") adapters = (PROJECT / "NodeAdapters.cs").read_text(encoding="utf-8") - runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8") - self.assertIn("ANSYS Mechanical 真机验收", form) - self.assertIn("运行内置基准验收", form) - self.assertIn("AcceptanceReportPassed", form) - self.assertIn('"ZCBOT_ANSYS_242_VALIDATED", "1"', form) - self.assertIn("EnvironmentVariableTarget.Machine", form) + runner = (PROJECT / "Adapters" / "AdapterExecutionService.cs").read_text( + encoding="utf-8" + ) + self.assertIn("ANSYS Mechanical 真机验收", xaml) + self.assertIn("运行内置基准验收", xaml) + self.assertIn("ReportPassed", service) + self.assertIn('GateEnvironmentVariable,\n "1"', service) + self.assertIn("EnvironmentVariableTarget.Machine", service) self.assertIn("SupportsLocalAcceptance", adapters) self.assertIn('Path.Combine(descriptor.DirectoryPath, "acceptance.py")', runner) self.assertIn( @@ -145,114 +300,100 @@ 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) - acceptance = form.split("private async Task RunAnsysAcceptanceAsync", 1)[1].split( - "private void EnableAnsysGate", 1 - )[0] - self.assertNotIn("new OpenFileDialog", acceptance) - self.assertNotIn("Process.Start", acceptance) + self.assertIn("service.RunAsync(workRoot", view_model) + self.assertNotIn("new OpenFileDialog", view_model + service) + self.assertNotIn("Process.Start", view_model + service) def test_professional_software_ui_uses_fixed_per_runtime_installers(self) -> None: - form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8") - manager = (PROJECT / "SoftwareRuntimeManager.cs").read_text(encoding="utf-8") - runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8") + xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text( + encoding="utf-8" + ) + service = ( + PROJECT / "Presentation" / "Services" / "SoftwareManagementService.cs" + ).read_text(encoding="utf-8") + catalog = (PROJECT / "Runtime" / "SoftwareCatalog.cs").read_text(encoding="utf-8") + manager = (PROJECT / "Runtime" / "SoftwareLocationService.cs").read_text( + encoding="utf-8" + ) + installer = (PROJECT / "Runtime" / "ManagedRuntimeInstaller.cs").read_text( + encoding="utf-8" + ) + runner = (PROJECT / "Adapters" / "AdapterExecutionService.cs").read_text( + encoding="utf-8" + ) - self.assertIn('CreateSectionTitle("专业软件")', form) - self.assertIn('CreateButton("自动检测", 92)', form) - self.assertIn('CreateButton("选择位置", 92)', form) - self.assertIn('CreateButton("安装 / 更新环境", 150', form) - self.assertIn("panel.SetColumnSpan(path, 2)", form) - self.assertIn("path.ReadOnly = true", form) - software_panel = form.split("private Control CreateSoftwarePanel", 1)[1].split( - "private void RefreshSoftwareCards", 1 - )[0] - self.assertIn("panel.SetColumnSpan(status, 2)", software_panel) - self.assertIn("panel.SetColumnSpan(actions, 2)", software_panel) - self.assertIn("SoftwareRuntimeManager.InstallRuntimeAsync", form) - self.assertIn("item.HasActiveJobs", form) + self.assertIn('Text="本机专业软件"', xaml) + self.assertIn('Content="自动检测"', xaml) + self.assertIn('Content="选择位置"', xaml) + self.assertIn('Content="安装 / 更新环境"', xaml) + self.assertIn('IsReadOnly="True"', xaml) + self.assertIn("ManagedRuntimeInstaller.InstallAsync", service) + self.assertIn("item.HasActiveJobs", service) for runtime_id in ('"origin"', '"ansys"', '"blender"'): - self.assertIn(runtime_id, manager) + self.assertIn(runtime_id, catalog) self.assertIn('@"Software\\Zcbot\\WindowsNode\\Software"', manager) self.assertIn("RegistryHive.LocalMachine", manager) self.assertIn("RegistryHive.CurrentUser", manager) - self.assertIn('"ZCBOT_BLENDER_EXE"', manager) - self.assertIn('"AWP_ROOT242"', manager) - self.assertIn('"ZCBOT_ORIGIN_EXE"', manager) - self.assertIn('"Origin*.exe"', manager) + self.assertIn('"ZCBOT_BLENDER_EXE"', catalog) + self.assertIn('"AWP_ROOT242"', catalog) + self.assertIn('"ZCBOT_ORIGIN_EXE"', catalog) + self.assertIn('"Origin*.exe"', catalog) self.assertIn("OriginExecutables", manager) - self.assertIn('Path.Combine(runtimes, definition.RuntimeId)', manager) - self.assertIn("ArgumentList.Add(argument)", manager) - self.assertNotIn('"cmd.exe"', manager.lower()) - self.assertNotIn('"powershell.exe"', manager.lower()) - self.assertIn("SoftwareRuntimeManager.ApplyProcessEnvironment", runner) + self.assertIn('Path.Combine(runtimes, definition.RuntimeId)', installer) + self.assertIn("ArgumentList.Add(argument)", installer) + self.assertNotIn('"cmd.exe"', installer.lower()) + self.assertNotIn('"powershell.exe"', installer.lower()) + self.assertIn("SoftwareLocationService.ApplyProcessEnvironment", runner) + self.assertIn("AdapterCatalog.HasActiveExecution", installer) def test_configuration_window_is_resizable_and_dpi_safe(self) -> None: - form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8") - self.assertIn("ClientSize = new Size(1080, 1050)", form) - self.assertIn("MinimumSize = new Size(760, 560)", form) - self.assertIn("FormBorderStyle.Sizable", form) - self.assertIn("AutoScaleMode.Dpi", form) - self.assertIn("AutoScroll = true", form) - self.assertNotIn("MaximumSize = new Size(410", form) - self.assertIn("注册码默认 10 分钟有效", form) - self.assertIn("成功注册一次后立即失效", form) - self.assertIn("CreateSection", form) - self.assertIn("注册并连接", form) - self.assertNotIn("ContentWidth", form) - self.assertIn("new Padding(24, 20, 24, 20)", form) - self.assertIn("new ColumnStyle(SizeType.Percent, 100)", form) - self.assertIn('AddTab(tabs, "节点概览")', form) - self.assertIn('AddTab(tabs, "专业软件")', form) - self.assertIn('AddTab(tabs, "本机任务")', form) - self.assertIn('AddTab(tabs, "运行设置")', form) - self.assertIn("SizeMode = TabSizeMode.FillToRight", form) - self.assertIn("new NavigationTabControl", form) - self.assertIn("DrawMode = TabDrawMode.OwnerDrawFixed", form) - self.assertIn("Appearance = TabAppearance.FlatButtons", form) - self.assertNotIn("protected override void OnResize", form) - self.assertIn("tab.Controls.Add(content)", form) - self.assertIn("CreateSectionTitle(\"节点信息\")", form) - self.assertIn("CreateSubsectionTitle(\"可用软件\")", form) - self.assertIn("heading.Controls.Add(state, 1, 0)", form) - self.assertNotIn("headingHint", form) - self.assertIn("panel.SetColumnSpan(path, 2)", form) - self.assertIn("panel.SetColumnSpan(status, 2)", form) - self.assertIn("panel.SetColumnSpan(actions, 2)", form) - self.assertIn("BorderedTableLayoutPanel", form) - self.assertIn( - 'runtime.Software == "OriginPro" ? "Origin" : runtime.Software', form + xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text( + encoding="utf-8" ) - self.assertIn("FormatCapabilitySummary(adapters.All)", form) - self.assertIn("GroupBy(entry => entry.SoftwareName", form) - self.assertIn("GroupBy(entry => entry.CapabilityName", form) - self.assertIn("ShortCapabilityName(adapter.DisplayName, softwareName)", form) - self.assertIn('CreateButton("立即重连", 112, primary: true)', form) - self.assertIn("ReconnectRequested?.Invoke()", form) - self.assertIn("registrationCard.Visible = !registered", form) - self.assertIn("reconnect.Visible = registered", form) - self.assertIn("resetIdentity.Visible = registered", form) - self.assertIn('CreateSectionTitle("本机任务")', form) - self.assertIn("DataGridView", form) - self.assertIn("jobInbox.ReadJobSnapshots()", form) - self.assertIn('"software_running" => "软件执行中"', form) - self.assertNotIn("HttpClient", form) + self.assertIn('Width="1040"', xaml) + self.assertIn('MinWidth="760"', xaml) + self.assertIn('MinHeight="560"', xaml) + self.assertIn(" None: - tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8") - connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8") + tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text( + encoding="utf-8" + ) + connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8") + host_loop = (PROJECT / "Host" / "NodeConnectionLoop.cs").read_text( + encoding="utf-8" + ) + controller = ( + PROJECT / "Application" / "NodeApplicationController.cs" + ).read_text(encoding="utf-8") self.assertIn("private async Task ExitNodeAsync()", tray) - self.assertIn("if (exiting || config is null", tray) - self.assertIn("ReadJobSnapshots()", tray) + self.assertIn("if (exiting)", tray) + self.assertIn("controller.ActiveJobCount", tray) self.assertIn("MessageBoxButtons.YesNoCancel", tray) - self.assertIn("connectionLoop?.CancelActiveJobsForExit()", tray) + self.assertIn("NodeShutdownMode.CancelJobs", tray) self.assertIn("tray.ContextMenuStrip.Enabled = false", tray) - self.assertLess(tray.index("await connectionTask"), tray.index("ExitThread();")) - self.assertIn("internal void CancelActiveJobsForExit()", connection) + self.assertLess(tray.index("await controller.StopAsync"), tray.index("shutdown();")) + self.assertIn("stopTask ??= StopOnceAsync(mode)", controller) + self.assertIn("connection?.CancelActiveJobsForExit()", controller) + self.assertIn("await StopConnectionAsync()", controller) + self.assertIn("public void CancelActiveJobsForExit()", host_loop) + self.assertIn("coordinator.CancelActiveJobsForExit()", host_loop) self.assertIn("adapters.Find(job.Capability)?.Cancel(job.JobId)", connection) self.assertIn("inputDownloader.DownloadAsync(job, forcedStop.Token)", connection) self.assertIn('"NODE_EXIT_CANCELLED"', connection) @@ -260,11 +401,20 @@ class WindowsNodeSourceTests(unittest.TestCase): def test_local_job_monitor_is_persisted_and_software_neutral(self) -> None: inbox = (PROJECT / "JobInboxStore.cs").read_text(encoding="utf-8") - connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8") + monitor = ( + PROJECT / "Application" / "JobMonitorService.cs" + ).read_text(encoding="utf-8") + connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8") models = (PROJECT / "JobMonitorModels.cs").read_text(encoding="utf-8") - self.assertIn('"state.json"', inbox) - self.assertIn("ReadJobSnapshots", inbox) - self.assertIn("AtomicWrite(path, content, overwrite: true)", inbox) + repository = (PROJECT / "Jobs" / "JobRepository.cs").read_text( + encoding="utf-8" + ) + state_machine = (PROJECT / "Jobs" / "JobStateMachine.cs").read_text( + encoding="utf-8" + ) + self.assertIn('"state.json"', monitor) + self.assertIn("ReadSnapshots", monitor) + self.assertIn("AtomicFile.Write(path, content, overwrite: true)", repository) for stage in ( "accepted", "downloading_inputs", @@ -275,26 +425,36 @@ class WindowsNodeSourceTests(unittest.TestCase): "failed", "cancelled", ): - self.assertIn(f'"{stage}"', inbox + connection) + self.assertIn( + f'"{stage}"', + inbox + monitor + repository + state_machine + connection, + ) self.assertIn("JobDisplaySnapshot", models) - self.assertNotIn("origin_running", inbox + connection + models) + self.assertNotIn("origin_running", inbox + monitor + connection + models) def test_startup_and_runtime_installation_are_owned_by_the_ui(self) -> None: startup = (PROJECT / "StartupRegistration.cs").read_text(encoding="utf-8") - form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8") - manager = (PROJECT / "SoftwareRuntimeManager.cs").read_text(encoding="utf-8") + xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text( + encoding="utf-8" + ) + view_model = ( + PROJECT / "Presentation" / "ViewModels" / "MainWindowViewModel.cs" + ).read_text(encoding="utf-8") + installer = (PROJECT / "Runtime" / "ManagedRuntimeInstaller.cs").read_text( + encoding="utf-8" + ) self.assertFalse((ROOT / "install-windows-node.bat").exists()) - self.assertIn('Text = "登录 Windows 后自动启动节点"', form) - self.assertIn("StartupRegistration.SetEnabled", form) + self.assertIn('Content="登录 Windows 后自动启动节点"', xaml) + self.assertIn("startupRegistration.SetEnabled", view_model) self.assertIn('@"Software\\Microsoft\\Windows\\CurrentVersion\\Run"', startup) self.assertIn("Environment.ProcessPath", startup) - self.assertIn("FindPython312Async", manager) - self.assertIn('new BootstrapPython("py.exe", ["-3.12"])', manager) - self.assertIn('new BootstrapPython("python.exe", [])', manager) - self.assertIn('"ZCBOT_PIP_INDEX_URL"', manager) + self.assertIn("FindPython312Async", installer) + self.assertIn('new BootstrapPython("py.exe", ["-3.12"])', installer) + self.assertIn('new BootstrapPython("python.exe", [])', installer) + self.assertIn('"ZCBOT_PIP_INDEX_URL"', installer) self.assertIn( - '"https://pypi.tuna.tsinghua.edu.cn/simple/"', manager + '"https://pypi.tuna.tsinghua.edu.cn/simple/"', installer ) def test_publish_output_contains_the_complete_node_payload(self) -> None: @@ -354,30 +514,36 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertFalse((ROOT / "package-windows-node.ps1").exists()) def test_auth_rejection_is_distinct_from_http_websocket_handshake_failure(self) -> None: - connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8") - self.assertIn('socket.HttpStatusCode is HttpStatusCode.Unauthorized', connection) - self.assertIn('or HttpStatusCode.Forbidden', connection) - self.assertIn("WebSocket 握手被拒绝,请检查服务端或反向代理", connection) - self.assertIn("catch (NodeEndpointException exception)", connection) - self.assertIn("throw new NodeEndpointException", connection) - self.assertIn("(int?)result.CloseStatus == 4003", connection) - self.assertIn("节点身份已被服务端拒绝", connection) - self.assertNotIn("Node credentials were rejected", connection) + client = (PROJECT / "Protocol" / "NodeProtocolClient.cs").read_text( + encoding="utf-8" + ) + session = (PROJECT / "Host" / "NodeSession.cs").read_text(encoding="utf-8") + self.assertIn('socket.HttpStatusCode is HttpStatusCode.Unauthorized', client) + self.assertIn('or HttpStatusCode.Forbidden', client) + self.assertIn("WebSocket 握手被拒绝,请检查服务端或反向代理", session) + self.assertIn("catch (NodeEndpointException exception)", session) + self.assertIn("throw new NodeEndpointException", client) + self.assertIn("(int?)result.CloseStatus == 4003", client) + self.assertIn("节点身份已被服务端拒绝", client) + self.assertNotIn("Node credentials were rejected", client + session) def test_adapter_runtime_probe_is_worker_owned_and_reported(self) -> None: - runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8") + runner = (PROJECT / "Adapters" / "AdapterExecutionService.cs").read_text( + encoding="utf-8" + ) worker = ( ROOT / "adapters" / "origin.plot@v2" / "worker.py" ).read_text(encoding="utf-8") - connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8") + connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8") self.assertIn('r"Origin.ApplicationSI\\CLSID"', worker) self.assertIn('["--probe"]', runner) self.assertIn('root.GetProperty("adapter_version")', runner) self.assertIn('startInfo.Environment["PYTHONUTF8"] = "1"', runner) self.assertIn('startInfo.Environment["PYTHONIOENCODING"] = "utf-8"', runner) - form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8") - self.assertIn('AppendLine($"Adapter: {adapter.AdapterVersion}")', form) - self.assertIn('$"{group.Key} · 版本未知"', form) + diagnostics = ( + PROJECT / "Application" / "DiagnosticService.cs" + ).read_text(encoding="utf-8") + self.assertIn('AppendLine($"Adapter: {adapter.AdapterVersion}")', diagnostics) self.assertIn( "&& !jobInbox.HasPendingJobs", connection, @@ -385,7 +551,7 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertIn("originAdapter?.HasActiveJobs", connection) self.assertIn( "ReadRecoverableJobs().Any(item => item.Terminal is null)", - (PROJECT / "JobInboxStore.cs").read_text(encoding="utf-8"), + (PROJECT / "Jobs" / "JobRepository.cs").read_text(encoding="utf-8"), ) self.assertNotIn("CreateInstance", worker) for marker in ( @@ -398,7 +564,22 @@ class WindowsNodeSourceTests(unittest.TestCase): def test_job_offer_is_persisted_before_acceptance(self) -> None: inbox = (PROJECT / "JobInboxStore.cs").read_text(encoding="utf-8") - connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8") + monitor = ( + PROJECT / "Application" / "JobMonitorService.cs" + ).read_text(encoding="utf-8") + repository = (PROJECT / "Jobs" / "JobRepository.cs").read_text( + encoding="utf-8" + ) + recovery = (PROJECT / "Jobs" / "JobRecoveryService.cs").read_text( + encoding="utf-8" + ) + atomic_file = (PROJECT / "Persistence" / "AtomicFile.cs").read_text( + encoding="utf-8" + ) + connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8") + protocol_client = (PROJECT / "Protocol" / "NodeProtocolClient.cs").read_text( + encoding="utf-8" + ) adapters = (PROJECT / "NodeAdapters.cs").read_text(encoding="utf-8") self.assertIn("adapters.Find(capability)", inbox) self.assertIn("adapter.ValidateRequest(request)", inbox) @@ -411,47 +592,51 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertNotIn('AppContext.BaseDirectory, "software-contracts"', adapters) self.assertNotIn("IsValidOriginRequest", inbox) self.assertNotIn("IsValidPlot", inbox) - self.assertIn('root.TryGetProperty("input_transfers"', inbox) - self.assertIn('root.TryGetProperty("request_summary"', inbox) - self.assertIn("ReadDisplayTitle(root)", inbox) - self.assertIn("foreach (var action in operation.EnumerateObject())", inbox) + self.assertIn('root.TryGetProperty("input_transfers"', inbox + monitor) + self.assertIn('root.TryGetProperty("request_summary"', inbox + monitor) + self.assertIn("ReadDisplayTitle(root)", monitor) + self.assertIn("foreach (var action in operation.EnumerateObject())", monitor) self.assertIn("transfers.GetArrayLength() > 16", inbox) self.assertNotIn("transfers.GetArrayLength() is < 1 or > 16", inbox) - self.assertIn('"input", key, filename', inbox) - self.assertIn("FileOptions.WriteThrough", inbox) - self.assertIn("stream.Flush(flushToDisk: true)", inbox) + self.assertIn('"input", key, filename', repository) + self.assertIn("FileOptions.WriteThrough", atomic_file) + self.assertIn("stream.Flush(flushToDisk: true)", atomic_file) new_record = inbox.split("var record =", 1)[1].split("private static JsonElement?", 1)[0] self.assertLess( - new_record.index("AtomicWrite(requestPath, record"), + new_record.index("AtomicFile.Write(requestPath, record"), new_record.index("JobOfferResult.Accept"), ) self.assertIn('offerResult.Accepted ? "job_accept" : "job_reject"', connection) - self.assertIn("sendLock.WaitAsync", connection) + self.assertIn("sendLock.WaitAsync", protocol_client) self.assertIn("!jobInbox.HasPendingJobs", connection) self.assertIn("capability_runtime = capabilityRuntime", connection) self.assertIn("adapter.RunAsync(job)", connection) self.assertIn("ReportRecoverableJobsAsync", connection) self.assertIn("ConcurrentDictionary jobPipelines", connection) - self.assertIn("StartJobPipeline(socket, acceptedJob)", connection) + self.assertIn("StartJobPipeline(client, acceptedJob)", connection) self.assertIn("inputDownloader.DownloadAsync(job, forcedStop.Token)", connection) self.assertNotIn("CreateLinkedTokenSource(cancellationToken, forcedStop.Token)", connection) self.assertIn('stage = "uploading_outputs"', connection) self.assertIn('stage = "software_running"', connection) self.assertNotIn('stage = "origin_running"', connection) - self.assertIn("&& !job.UploadComplete", connection) - self.assertIn("&& !job.CloudTerminal", connection) + self.assertIn("JobRecoveryAction.ResumeOutputUpload", connection) + self.assertIn("job.UploadComplete || job.CloudTerminal", recovery) self.assertIn( "await Task.WhenAll(jobPipelines.Values.Concat(exportPipelines.Values).ToArray())", connection, ) - self.assertIn("StartJobPipeline(socket, job)", connection) + self.assertIn("StartJobPipeline(client, job, recovery: true)", connection) self.assertIn('stage = "downloading_inputs"', connection) - self.assertIn('Path.Combine(jobDirectory, "terminal.json")', inbox) - self.assertIn("AtomicWrite(requestPath, updated, overwrite: true)", inbox) + self.assertIn('Path.Combine(jobDirectory, "terminal.json")', repository) + self.assertIn("AtomicFile.Write(requestPath, updated, overwrite: true)", inbox) downloader = (PROJECT / "JobInputDownloader.cs").read_text(encoding="utf-8") - self.assertIn('new AuthenticationHeaderValue("Bearer", config.NodeToken)', downloader) - self.assertIn('DefaultRequestHeaders.Add("X-Node-Id"', downloader) + http_client = (PROJECT / "Transfers" / "NodeHttpClient.cs").read_text( + encoding="utf-8" + ) + self.assertIn('new AuthenticationHeaderValue("Bearer", config.NodeToken)', http_client) + self.assertIn('DefaultRequestHeaders.Add("X-Node-Id"', http_client) + self.assertIn("SendWithRetryAsync", downloader) self.assertIn("HttpCompletionOption.ResponseHeadersRead", downloader) self.assertIn("IncrementalHash.CreateHash", downloader) self.assertIn("total > expectedSize", downloader) @@ -459,8 +644,13 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertNotIn("Process.Start", downloader) def test_adapter_worker_launch_is_manifest_driven_and_terminal_driven(self) -> None: - runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8") - connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8") + runner = (PROJECT / "Adapters" / "AdapterExecutionService.cs").read_text( + encoding="utf-8" + ) + supervisor = (PROJECT / "Adapters" / "ProcessSupervisor.cs").read_text( + encoding="utf-8" + ) + connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8") project = (PROJECT / "Zcbot.WindowsNode.csproj").read_text(encoding="utf-8") worker = ( ROOT / "adapters" / "origin.plot@v2" / "worker.py" @@ -476,8 +666,8 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertIn('Path.Combine(jobDirectory, "terminal.json")', runner) self.assertIn('"NODE_RESTARTED_DURING_JOB"', runner) self.assertIn("CancellationTokenSource.CreateLinkedTokenSource", runner) - self.assertIn("process.Kill(entireProcessTree: true)", runner) - self.assertIn('type.GetString() == "job_cancel"', connection) + self.assertIn("process.Kill(entireProcessTree: true)", supervisor) + self.assertIn('message.Type == "job_cancel"', connection) self.assertIn('"cancelled", "USER_CANCELLED"', connection) self.assertIn("..\\adapters\\**\\*", project) self.assertIn("descriptor.Manifest.WorkerTimeoutMinutes", runner) @@ -498,24 +688,29 @@ class WindowsNodeSourceTests(unittest.TestCase): def test_origin_adapter_can_be_packaged_without_building_node(self) -> None: script = (ROOT / "package-origin-adapter.bat").read_text(encoding="utf-8") - connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8") + connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8") self.assertIn('"adapters\\origin.plot@v2\\adapter.json"', script) self.assertIn('"adapters\\origin.plot@v2\\worker.py"', script) self.assertIn('"adapters\\origin.plot@v2\\acceptance.py"', script) self.assertIn('"..\\software-contracts\\origin.plot.v2.json"', script) self.assertNotIn("dotnet", script.lower()) - form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8") - self.assertIn("Contract SHA-256: {adapter.ContractSha256}", form) - self.assertIn("Workspace protocol:", form) - self.assertIn("Workspace state:", form) + diagnostics = ( + PROJECT / "Application" / "DiagnosticService.cs" + ).read_text(encoding="utf-8") + self.assertIn("Contract SHA-256: {adapter.ContractSha256}", diagnostics) + self.assertIn("Workspace protocol:", diagnostics) + self.assertIn("Workspace state:", diagnostics) self.assertIn("workspace_protocol_version", connection) self.assertIn("contract_sha256", connection) uploader = (PROJECT / "JobOutputUploader.cs").read_text(encoding="utf-8") - self.assertIn('new AuthenticationHeaderValue("Bearer", config.NodeToken)', uploader) - self.assertIn('DefaultRequestHeaders.Add("X-Node-Id"', uploader) - self.assertIn('DefaultRequestHeaders.Add("X-Lease-Id"', uploader) + http_client = (PROJECT / "Transfers" / "NodeHttpClient.cs").read_text( + encoding="utf-8" + ) + self.assertIn('new AuthenticationHeaderValue("Bearer", config.NodeToken)', http_client) + self.assertIn('DefaultRequestHeaders.Add("X-Node-Id"', http_client) + self.assertIn('request.Headers.Add("X-Lease-Id"', http_client) self.assertIn("SHA256.HashDataAsync", uploader) self.assertIn("upload-complete.json", connection + uploader) self.assertIn("adapter.PreviewOutputIds", connection) @@ -539,11 +734,17 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertNotIn('".tmp-" + Guid.NewGuid()', uploader) self.assertNotIn("Process.Start", uploader) - tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8") - self.assertIn("_ = form.Handle", tray) + tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text( + encoding="utf-8" + ) + controller = ( + PROJECT / "Application" / "NodeApplicationController.cs" + ).read_text(encoding="utf-8") + self.assertNotIn("legacyForm", tray) + self.assertIn("window.ShowAndActivate()", tray) self.assertIn( - 'UpdateStatus(NodeStatus.Create(NodeState.Connecting, "正在连接 zcbot…"))', - tray, + 'Publish(NodeStatus.Create(NodeState.Connecting, "正在连接 zcbot…"))', + controller, ) def test_origin_analysis_adapter_can_be_packaged_without_building_node(self) -> None: @@ -576,8 +777,10 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertNotIn("dotnet", script.lower()) def test_local_job_monitor_never_shows_update_before_acceptance(self) -> None: - inbox = (PROJECT / "JobInboxStore.cs").read_text(encoding="utf-8") - self.assertIn("state.UpdatedAt > acceptedAt", inbox) + monitor = ( + PROJECT / "Application" / "JobMonitorService.cs" + ).read_text(encoding="utf-8") + self.assertIn("state.UpdatedAt > acceptedAt", monitor) if __name__ == "__main__": diff --git a/windows-node/README.md b/windows-node/README.md index 36cd0a9..9b4519f 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` 发现能力;每个 adapter 目录必须自带 manifest 指向的合同,Node 不读取共享合同目录。Host 只按该合同的 JSON Schema 通用校验请求,软件探测、语义校验和执行都由 Worker 完成。Worker 可为受管 Python 脚本或独立 EXE,manifest 独立声明每种软件的任务超时及可选 Workspace 状态。当前安装包带 Origin、ANSYS 和 `blender.scene.author@v3` Worker,但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录;Origin OPJU 与 Blender `.blend` 工程保存在各自的 `workspaces//current`,只保留一份 `rollback`,完成时自动上传轻量预览,用户明确请求导出时才上传并发布所选正式产物。 +当前 GUI 使用 WPF 主窗口与托盘常驻,原生提供节点状态、首次注册、重连、清除身份、诊断复制、登录自启动、数据目录迁移、本机任务列表与详情,以及 Origin/ANSYS/Blender 软件卡和 ANSYS 固定验收;可选择应用位置、恢复自动检测并安装或更新每种软件的独立 runtime。WinForms 只保留托盘、受控选择对话框、确认框和剪贴板互操作,不再存在经典配置窗口。节点继续支持 DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连。Node Host 以语言无关的 job 目录协议负责持久化、下载、恢复、取消和上传,并从 EXE 同级 `adapters/*/adapter.json` 发现能力;每个 adapter 目录必须自带 manifest 指向的合同,Node 不读取共享合同目录。Host 只按该合同的 JSON Schema 通用校验请求,软件探测、语义校验和执行都由 Worker 完成。Worker 可为受管 Python 脚本或独立 EXE,manifest 独立声明每种软件的任务超时及可选 Workspace 状态。当前安装包带 Origin、ANSYS 和 `blender.scene.author@v3` Worker,但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录;Origin OPJU 与 Blender `.blend` 工程保存在各自的 `workspaces//current`,只保留一份 `rollback`,完成时自动上传轻量预览,用户明确请求导出时才上传并发布所选正式产物。 在仓库根目录执行一条命令生成可分发 ZIP: @@ -39,7 +39,7 @@ 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 条已接收任务及执行详情。每个任务的输出上传诊断日志保存在 `\jobs\\logs\node-output-upload.log`,记录阶段、产物文件名、重试次数和 Windows `HRESULT`,不记录 Node Token 或认证请求头;单文件达到 1 MiB 后轮转一份 `.1`。无界面运行使用: +直接双击 EXE 默认启动托盘 UI;红点表示未注册或身份失效,黄点表示正在连接,绿点表示在线。双击托盘图标打开 WPF 主窗口;“本机任务”页每秒刷新已派发到本机的最近记录,选择一行可查看阶段、能力、更新时间和完整 Job ID;ANSYS 验收位于“专业软件”页,数据目录打开与迁移位于“运行设置”页。每个任务的输出上传诊断日志保存在 `\jobs\\logs\node-output-upload.log`,记录阶段、产物文件名、重试次数和 Windows `HRESULT`,不记录 Node Token 或认证请求头;单文件达到 1 MiB 后轮转一份 `.1`。无界面运行使用: ```powershell Zcbot.WindowsNode.exe run --headless diff --git a/windows-node/WPF_HOST_REFACTOR.md b/windows-node/WPF_HOST_REFACTOR.md new file mode 100644 index 0000000..2f203c3 --- /dev/null +++ b/windows-node/WPF_HOST_REFACTOR.md @@ -0,0 +1,774 @@ +# Windows Node Host 与 WPF 重构方案 + +> 状态:阶段 0–7 已落地,进入阶段 8 发布准备 +> 日期:2026-08-31 +> 适用范围:`windows-node/Zcbot.WindowsNode` 及其测试、打包与运行文档 + +## 1. 背景 + +当前 Windows Node 已完成托盘常驻、节点注册、WebSocket 连接、任务持久化与恢复、Adapter 发现与执行、Workspace 管理、专业软件配置、受管 runtime 安装、本机任务查看、数据目录迁移和 ANSYS 真机验收。它已经从一个简单的内网托盘 MVP 演进为专业软件任务的稳定宿主。 + +当前实现仍是单个 .NET 10 WinForms `WinExe`,后台协议与执行能力总体边界正确,但 UI 和 Host 内部职责开始集中: + +- `ConfigurationForm.cs` 约 1300 行,布局、状态、软件管理、任务展示、数据迁移和 ANSYS 验收混在同一窗口类中。 +- `TrayApplicationContext.cs` 同时管理托盘、窗口、注册、连接、重连、迁移和安全退出。 +- `NodeConnectionLoop.cs` 同时承担 WebSocket 会话、心跳、消息解析、任务编排、恢复、取消与终态回报。 +- `JobInboxStore.cs` 同时承担协议接单、磁盘持久化、状态流转、恢复扫描和 UI 展示读取。 +- `SoftwareRuntimeManager.cs` 以大体量静态类同时承担软件定义、位置存储、探测、环境注入和 runtime 安装。 +- 注册、下载和上传分别管理 HTTP 客户端,网络错误分类、重试和认证注入缺少统一边界。 +- 现有 Windows Node 测试中有较多源码字符串断言,能够保护若干安全约束,但难以验证真实状态转换和异步生命周期。 + +本次重构同时处理两个问题: + +1. 将 WinForms 主窗口迁移为 WPF,使用 XAML、数据绑定和 MVVM 组织界面。 +2. 将 Node Host 重构为可测试、可组合、与 UI 无关的长期宿主架构。 + +本次不是服务端协议重写,也不是 Adapter 架构替换。 + +## 2. 目标 + +### 2.1 用户与运维目标 + +- 保持现有节点身份、数据目录、任务、Workspace 和 runtime 原位兼容。 +- 保持解压发布包后直接运行的部署方式。 +- 保持同一 EXE 的托盘、`enroll` 和 `run --headless` 三种入口。 +- 提升 125%、150% 和 200% DPI 下的布局稳定性。 +- 让节点状态、软件状态、任务状态和长时间操作在界面中保持一致。 +- 让新增专业软件主要落在 Adapter 和软件定义,不再扩张单个巨型窗口。 +- 在出现连接、传输、Worker 或恢复故障时提供可定位但不泄露 Token 的本机诊断。 + +### 2.2 工程目标 + +- 建立唯一 Composition Root,GUI 与 headless 共用同一套 Host 组装。 +- 将 UI、应用编排、领域状态、协议传输和基础设施分层。 +- 以明确的 Host 生命周期替代托盘类直接管理连接任务。 +- 以明确的 Job 状态机约束接单、下载、执行、上传、取消和恢复。 +- 统一 HTTP、WebSocket、认证、错误分类和传输重试边界。 +- 统一 Adapter 目录发现、合同加载、软件 probe 和进程监督边界。 +- 对纯逻辑和关键异步流程增加 .NET 行为测试,减少脆弱的源码字符串测试。 +- 每个实施阶段都可构建、可打包、可验收和可回退。 + +## 3. 非目标 + +以下事项不随本轮重构进入实现: + +- 不改服务端 HTTP/WebSocket URL、消息类型、字段语义或认证方式。 +- 不改 `node.json`、`jobs/`、`workspaces/`、`runtimes/` 的对外文件布局。 +- 不清理、重置或隐式迁移已有节点数据。 +- 不改 Adapter 的 `adapter.json + contract + worker + job directory` 协议。 +- 不引入 Python GUI、Electron、WebView 或 WinUI 3。 +- 不安装 Windows Service,不拆 Service/DesktopRunner 双进程。 +- 不增加自动更新平台、插件市场或在线 Adapter 覆盖。 +- 不增加多 Job 并行或 per-capability slot;继续保持整机单执行槽。 +- 不把 Adapter probe、软件语义或具体软件分支移入通用 Host。 +- 不在框架迁移时同时进行大规模视觉重设计。 + +## 4. 必须保持的兼容契约 + +| 契约 | 重构后要求 | +|---|---| +| 可执行文件 | 仍为 `Zcbot.WindowsNode.exe` | +| GUI 启动 | 无参数启动托盘;未注册时显示主窗口,已注册时后台连接 | +| CLI | `enroll` 参数、输出标签和退出码不变 | +| Headless | `run --headless` 不初始化 WPF、窗口或托盘 | +| 单实例 | 继续使用 `Local\\Zcbot.WindowsNode` Mutex | +| 节点身份 | 继续读取现有 DPAPI `LocalMachine` 加密 Token | +| 注册表 | 数据根目录、软件位置和当前账号 Run 项键名不变 | +| 数据目录 | 默认值、环境变量优先级和旧 `%ProgramData%` 兼容不变 | +| Job | 已落盘任务可由新版继续恢复、取消、上传和回报终态 | +| Workspace | `current`、`rollback` 和 head Job 语义不变 | +| Adapter | 继续从 EXE 同级 `adapters/*/adapter.json` 发现 | +| 发布 | 完整包和独立 Adapter 包的目录结构不变 | +| 服务端 | 无 DB migration、无 API 变化、无部署联动要求 | + +任何需要改变以上契约的实现发现都必须暂停,单独形成兼容设计,不得借重构直接修改。 + +## 5. 总体架构 + +```mermaid +flowchart TD + Entry["Program / Composition Root"] + Tray["TrayHost"] + Wpf["WPF Views"] + VM["ViewModels"] + App["Application Services"] + Host["NodeHost"] + Session["NodeSession"] + Jobs["JobCoordinator"] + Transfers["TransferClient"] + Adapters["AdapterCatalog / AdapterExecutor"] + Persistence["Config / Job / Workspace Repositories"] + Runtime["SoftwareRuntimeService"] + Server["zcbot HTTP / WebSocket"] + Workers["Python or EXE Workers"] + Disk["Existing data root"] + + Entry --> Tray + Entry --> Host + Tray --> Wpf + Wpf --> VM + VM --> App + Tray --> App + App --> Host + Host --> Session + Host --> Jobs + Jobs --> Transfers + Jobs --> Adapters + Jobs --> Persistence + App --> Runtime + Session --> Server + Transfers --> Server + Adapters --> Workers + Persistence --> Disk + Runtime --> Disk +``` + +### 5.1 分层规则 + +#### Presentation + +包含 WPF View、ViewModel、托盘和对话框适配器。 + +- 可以引用 Application。 +- 不直接引用 `ClientWebSocket`、`HttpClient`、Registry、文件系统或进程。 +- XAML code-behind 只处理纯 View 行为,例如 PasswordBox 取值、窗口拖动和焦点。 +- 不在 `MainWindow.xaml.cs` 中编排节点业务。 + +#### Application + +包含用户操作和宿主生命周期的用例编排。 + +- 注册、重连、清除身份、迁移数据目录、安全退出。 +- 软件位置变更、runtime 安装和固定验收。 +- 组合领域结果为不可变 UI Snapshot。 +- 不依赖 WPF 或 WinForms 类型。 + +#### Host / Domain + +包含节点会话、Job 状态机、单槽执行、恢复和 Adapter 抽象。 + +- 不感知托盘、窗口、按钮或对话框。 +- 不通过任意字符串隐式改变 Job 状态。 +- 关键状态转换经过统一校验和持久化。 + +#### Infrastructure + +包含 HTTP、WebSocket、JSON、DPAPI、Registry、文件、进程和软件探测实现。 + +- 向上提供明确的小接口或服务,不把 Win32/IO 类型扩散到 ViewModel。 +- 原子写、路径约束、日志脱敏和进程树终止在此集中实现。 + +## 6. 目标项目结构 + +第一轮保持单一 `Zcbot.WindowsNode.csproj`,先形成目录和命名空间边界,避免同时承担程序集拆分、`internal` 可见性和发布路径变化。完成后再依据依赖图决定是否拆出测试友好的 Core 项目。 + +```text +Zcbot.WindowsNode/ +├── Program.cs +├── App.xaml +├── App.xaml.cs +│ +├── Bootstrap/ +│ ├── NodeCompositionRoot.cs +│ ├── NodeCommandLine.cs +│ └── SingleInstanceGuard.cs +│ +├── Application/ +│ ├── NodeApplicationController.cs +│ ├── NodeApplicationSnapshot.cs +│ ├── NodeShutdownDecision.cs +│ ├── DiagnosticService.cs +│ ├── JobMonitorService.cs +│ ├── SoftwareManagementService.cs +│ └── AnsysAcceptanceService.cs +│ +├── Host/ +│ ├── NodeHost.cs +│ ├── NodeHostSnapshot.cs +│ ├── NodeSession.cs +│ ├── NodeSessionState.cs +│ └── NodeEvent.cs +│ +├── Protocol/ +│ ├── NodeProtocolClient.cs +│ ├── NodeProtocolCodec.cs +│ ├── ServerMessage.cs +│ ├── NodeMessage.cs +│ └── NodeProtocolException.cs +│ +├── Jobs/ +│ ├── JobCoordinator.cs +│ ├── JobExecutionGate.cs +│ ├── JobStateMachine.cs +│ ├── JobRepository.cs +│ ├── JobRecoveryService.cs +│ └── JobSnapshot.cs +│ +├── Transfers/ +│ ├── NodeHttpClient.cs +│ ├── JobInputTransfer.cs +│ ├── JobOutputTransfer.cs +│ └── TransferRetryPolicy.cs +│ +├── Adapters/ +│ ├── AdapterCatalog.cs +│ ├── AdapterDescriptor.cs +│ ├── AdapterContractLoader.cs +│ ├── AdapterExecutionService.cs +│ └── ProcessSupervisor.cs +│ +├── Workspaces/ +│ ├── WorkspaceRepository.cs +│ └── WorkspacePromotionService.cs +│ +├── Runtime/ +│ ├── SoftwareCatalog.cs +│ ├── SoftwareLocationService.cs +│ ├── SoftwareProbeService.cs +│ └── ManagedRuntimeInstaller.cs +│ +├── Persistence/ +│ ├── NodeConfigStore.cs +│ ├── NodeDataRootService.cs +│ ├── AtomicFile.cs +│ ├── PathGuard.cs +│ └── RotatingNodeLog.cs +│ +├── Presentation/ +│ ├── Tray/ +│ ├── ViewModels/ +│ ├── Views/ +│ ├── Commands/ +│ ├── Converters/ +│ └── Themes/ +│ +└── Compatibility/ + └── persisted model readers retained only where required +``` + +目录是目标职责图,不要求通过一次机械移动完成;重构期间优先保证逻辑边界,最后再统一物理目录。 + +## 7. Host 核心设计 + +### 7.1 Composition Root + +`NodeCompositionRoot` 是唯一对象组装入口: + +- 解析一次数据根目录并生成 `NodePaths`。 +- 创建唯一 `NodeConfigStore`、`JobRepository`、`WorkspaceRepository` 和 `AdapterCatalog`。 +- 按当前节点配置创建共享 `NodeHttpClient` 和 `NodeProtocolClient`。 +- 创建 `JobCoordinator`、`NodeSession` 和 `NodeHost`。 +- GUI 与 headless 只决定 Presentation 和生命周期宿主,不各自复制 Host 构造逻辑。 + +不引入通用依赖注入容器;当前规模使用显式构造函数组装更便于审计、打包和故障定位。 + +### 7.2 NodeHost 生命周期 + +`NodeHost` 对外只暴露少量稳定操作: + +```csharp +Task RunAsync(CancellationToken cancellationToken); +Task ReconnectAsync(CancellationToken cancellationToken); +Task StopAsync(NodeShutdownMode mode, CancellationToken cancellationToken); +NodeHostSnapshot Current { get; } +event EventHandler? SnapshotChanged; +``` + +生命周期约束: + +- `RunAsync` 是结构化并发根,拥有会话、恢复、Job 和传输子任务。 +- 所有子任务都可追溯到 Host CancellationToken,不允许 fire-and-forget Worker 管线。 +- 重连先停止接收 offer,再等待本地任务和上传到达安全点,之后替换会话。 +- 正常退出与取消任务退出使用同一 `StopAsync`,GUI 和 headless 不复制收尾逻辑。 +- `StopAsync` 幂等;重复点击退出或系统注销不会启动第二条取消链。 +- Snapshot 是不可变值,UI 只消费 Snapshot,不读取 Host 内部集合。 + +### 7.3 WebSocket 会话拆分 + +从 `NodeConnectionLoop` 提取: + +- `NodeSession`:连接、hello、心跳、接收循环和退避重连。 +- `NodeProtocolCodec`:JSON 消息的严格解析、类型分发和序列化。 +- `NodeProtocolClient`:并发安全发送和 WebSocket 关闭语义。 +- `JobCoordinator`:处理 offer、cancel 和导出等业务消息。 + +要求: + +- 一个会话只拥有一个接收循环。 +- 所有发送通过同一发送锁,避免并发 `SendAsync`。 +- 认证拒绝、握手 HTTP 错误、网络中断和协议错误继续保持不同状态。 +- 未知消息按现有兼容策略处理,不得把解析异常误报为身份失效。 +- 心跳携带的能力、health、slot 和合同摘要来自同一个原子 Snapshot。 + +### 7.4 Job 状态机 + +新增 `JobStateMachine` 作为内部唯一状态转换入口,覆盖: + +```text +offered + -> accepted + -> downloading + -> ready + -> software_running + -> promoting_workspace + -> uploading_preview / uploading_output + -> succeeded + +任意可取消阶段 -> cancelling -> cancelled +任意执行阶段 -> failed +进程重启 -> recovered according to persisted markers +``` + +具体落盘字符串和 JSON 字段保持现状;状态机首先包裹现有格式,不强制迁移历史文件。 + +要求: + +- offer 仍然先持久化并 flush/原子替换,再回复 accept。 +- Job ID、lease ID 和 request digest 一致性校验保持不变。 +- 终态不可逆;重复终态消息必须幂等。 +- 单执行槽由本机 `JobExecutionGate` 再次强制,不只依赖云端 slot。 +- Workspace promotion、预览上传和显式导出分别建模,不能因上传重试重复执行专业软件。 +- 进程重启恢复只依赖已落盘事实,不依赖 UI 内存状态。 + +### 7.5 Job 持久化拆分 + +将 `JobInboxStore` 拆为三个职责: + +- `JobRepository`:原子读写 Job、marker 和 terminal。 +- `JobRecoveryService`:启动扫描和恢复决策。 +- `JobMonitorService`:面向 UI 生成只读展示 Snapshot。 + +统一 `AtomicFile`: + +- 同目录临时文件。 +- 写入、flush、关闭后原子替换。 +- 原文件存在时保持当前替换语义。 +- 不用跨卷 rename。 +- 临时文件命名不与 Worker 协议文件冲突。 + +统一 `PathGuard`: + +- 所有 Job、Workspace、Adapter 和 runtime 派生路径必须归一化后验证仍位于预期根目录。 +- 不把请求内文件名直接拼入本机路径。 +- 保持合同内输出 ID 与实际文件 manifest 的现有校验。 + +### 7.6 传输层 + +创建每个已加载节点配置唯一的 `NodeHttpClient`: + +- 统一 BaseAddress、Bearer Token、User-Agent、默认超时和错误摘要。 +- 输入下载和输出上传复用连接池。 +- 大文件继续流式传输,不整体读入内存。 +- 重试只覆盖可安全重放的阶段,上传继续依赖服务端幂等校验。 +- 认证失败不进入普通网络重试。 +- 日志不记录 Authorization Header、Node Token、注册码或签名 URL 查询串。 + +`JobInputTransfer` 与 `JobOutputTransfer` 只负责传输;是否重试、何时回报终态由 `JobCoordinator` 决定。 + +### 7.7 Adapter 与进程监督 + +将当前 Adapter 发现和执行拆为: + +- `AdapterCatalog`:启动时一次发现、合同加载和描述符缓存。 +- `AdapterContractLoader`:manifest、合同路径、Schema 和 SHA-256 校验。 +- `AdapterExecutionService`:准备 Job 目录并调用固定 Adapter。 +- `ProcessSupervisor`:启动、标准流采集、超时、取消、完整进程树终止和退出确认。 + +安全边界保持: + +- Worker 入口必须来自已安装 manifest,不来自任务请求。 +- 入口归一化后必须位于对应 Adapter 目录。 +- Python 解释器必须来自固定受管 runtime 解析。 +- 请求不能注入命令行、环境变量、工作目录或本机路径。 +- 进程退出后才读取 terminal;取消后必须等待完整进程树退出。 +- Host 不解析 Origin、ANSYS、Blender 业务语义。 + +### 7.8 Workspace + +`WorkspaceRepository` 负责现有 metadata 和目录读取,`WorkspacePromotionService` 负责: + +- 校验 source head。 +- 在临时目录准备下一版。 +- 成功后更新 `rollback` 和 `current`。 +- 失败时保持旧 `current` 可继续使用。 +- 只保留一份 rollback。 + +重构不得增加按 Job 永久保存工程副本,也不得在 Node 间搬迁 Workspace。 + +### 7.9 软件与 runtime 管理 + +将 `SoftwareRuntimeManager` 拆为: + +- `SoftwareCatalog`:固定的软件和 runtime 定义。 +- `SoftwareLocationService`:注册表配置、自动检测和版本来源。 +- `SoftwareProbeService`:通过 Adapter probe 汇总最终 health。 +- `ManagedRuntimeInstaller`:固定 requirements、临时目录安装、验证和原子替换。 + +要求: + +- 软件卡继续按 `runtime_id` 归并多个 capability。 +- 环境变量优先级和现有兼容名称不变。 +- runtime 有活动任务时继续拒绝替换。 +- 安装命令、镜像地址和 requirements 不接受任务请求控制。 +- Origin 软件位置识别与 COM 健康继续分离。 +- ANSYS 执行门和固定验收继续由 Adapter/验收脚本给出事实结果。 + +## 8. WPF Presentation 设计 + +### 8.1 框架选择 + +- 继续使用 C# 和 .NET 10。 +- 项目启用 WPF。 +- 暂时保留 WinForms 引用,仅复用成熟的 `NotifyIcon`。 +- 不引入第三方 MVVM 或主题框架;先使用小型 `ObservableObject`、`RelayCommand`、`AsyncCommand` 和资源字典。 +- 首版使用浅色主题,颜色、字体、间距和控件状态全部抽为资源。 + +### 8.2 View 与 ViewModel + +```text +MainWindow +├── OverviewView / OverviewViewModel +├── SoftwareView / SoftwareViewModel +│ ├── SoftwareCardViewModel[] +│ └── AnsysAcceptanceViewModel +├── JobsView / JobsViewModel +└── SettingsView / SettingsViewModel +``` + +ViewModel 只接收 Application Service 和不可变 Snapshot;不直接 new Adapter Registry、Job Store 或路径对象。 + +### 8.3 状态绑定 + +以下状态由 Snapshot 或专用 ViewModel 字段统一驱动: + +- 节点连接状态、颜色、说明和操作可用性。 +- 注册卡是否显示。 +- 软件位置、runtime、probe 和 capability 状态。 +- runtime 安装、验收和数据迁移进度。 +- Job 列表、选中项、详情和刷新状态。 +- 退出期间全局操作禁用。 + +不允许同一状态同时由多个页面各自推断。 + +### 8.4 托盘与窗口生命周期 + +`TrayHost` 只处理: + +- 创建和释放 NotifyIcon。 +- 将 Host Snapshot 映射为图标、悬浮文本和菜单摘要。 +- 打开、隐藏和激活 WPF 主窗口。 +- 收集退出选择并调用 `NodeApplicationController.StopAsync`。 + +活动任务退出的三种选择、二次确认和等待提示保持现有语义。业务取消与进程收尾不在 TrayHost 内实现。 + +### 8.5 可访问性与 DPI + +- 使用设备无关布局,不写依赖当前字体像素测量的固定页面宽度。 +- 关键页面在 760×560 最小窗口和 100%–200% DPI 下可访问。 +- 长路径、长错误和任务标题允许换行、截断并提供完整详情。 +- 设置合理的 Tab 顺序、AccessKey、默认按钮和取消行为。 +- 不仅使用颜色表达在线、警告和失败状态。 +- 长时间操作必须有文本状态、进度反馈和可用时的取消入口。 + +## 9. 诊断与可观测性 + +在不引入远程日志平台的前提下,增加受控本机 Host 日志: + +- 路径位于现有 data root 的 `logs/`。 +- 使用 ASCII 级别标签:`[INFO]`、`[WARN]`、`[ERR]`。 +- 单文件有明确大小上限并只保留有限轮转文件。 +- 记录会话阶段、Job ID、capability、Adapter 版本、Worker PID、传输阶段和 HRESULT/异常类型。 +- 不记录 Node Token、注册码、Authorization Header、完整签名 URL 或用户输入文件内容。 +- “复制诊断信息”继续输出可粘贴摘要,不默认复制完整日志。 + +`NodeHostSnapshot` 至少包含: + +- 当前节点状态和状态变化时间。 +- 当前会话 ID 或本地连接代次,不包含凭据。 +- 活动 Job、当前阶段和开始时间。 +- 待恢复/待上传数量。 +- Adapter 数量、能力摘要和最近 probe 时间。 +- data root 和版本信息。 + +## 10. 测试策略 + +### 10.1 Characterization 测试 + +重构前固定当前行为: + +- CLI 参数、stdout/stderr 标签和退出码。 +- 未注册、在线、身份失效和普通断线状态差异。 +- offer 先落盘后 accept。 +- 重复 Job digest 校验。 +- 终态与上传重试幂等。 +- Workspace promotion 与 rollback。 +- 活动任务退出的等待和取消路径。 +- 数据目录迁移的停止、复制、校验和切换顺序。 + +### 10.2 .NET 单元测试 + +新增 `Zcbot.WindowsNode.Tests`,优先测试: + +- `JobStateMachine` 合法和非法转换。 +- `NodeHost` 启停、重复停止和重连屏障。 +- `NodeProtocolCodec` 消息分类及错误处理。 +- `JobRecoveryService` 对各持久化阶段的恢复决策。 +- `TransferRetryPolicy` 对认证、网络、服务端和取消错误的分类。 +- `AdapterContractLoader` 的路径、manifest、版本与合同校验。 +- ViewModel Snapshot 映射、Command 可用性和异步失败恢复。 + +测试使用临时目录和可控的 HTTP handler/协议替身;不连接 `.env` 中的数据库或生产服务。 + +### 10.3 集成测试 + +- 本地假 WebSocket 服务完成 hello、心跳、offer、cancel 和重连。 +- 固定测试 Worker 验证启动、超时、取消和进程树回收。 +- 临时 data root 验证重启恢复、原子写和 Workspace promotion。 +- framework-dependent 与 self-contained 两种发布包完成启动烟测。 +- 发布包验证 Adapter、合同、验收资产和 requirements 完整。 + +### 10.4 UI 验收 + +- 未注册首次启动。 +- 已注册后台启动和托盘显示。 +- 托盘双击、窗口隐藏和恢复。 +- 注册、重连、清身份和复制诊断。 +- 软件自动检测、手选、清除和 runtime 安装。 +- ANSYS 固定验收、取消、通过报告和执行门。 +- 本机任务刷新、选中详情和重启后恢复。 +- 活动任务等待退出、取消退出和返回。 +- 100%、125%、150%、200% DPI。 + +### 10.5 现有 Python 源码测试调整 + +保留适合静态审计的测试: + +- 项目目标框架和发布参数。 +- 不暴露任意执行原语。 +- Adapter 与合同是否完整打包。 +- Token 不以明文模型字段或日志字段出现。 +- 独立 Adapter 包不依赖重建 Node。 + +将控件名称、WinForms 布局字符串、按钮宽度等断言替换为 .NET 行为测试或 WPF 结构测试,避免测试阻止内部文件拆分。 + +## 11. 实施阶段 + +### 阶段 0:基线与保护网 + +- 固化功能、协议、持久化和发布包基线。 +- 增加关键生命周期 Characterization 测试。 +- 记录真机 DPI 与托盘行为。 +- 不改变生产行为。 + +完成门槛:现有 build、专项测试、打包和启动烟测全部通过。 + +### 阶段 1:Composition Root 与应用控制器 + +- 引入唯一 `NodeCompositionRoot`。 +- 从托盘类提取注册、连接、重连、迁移和退出编排。 +- GUI 与 headless 共用 Host 构造路径。 +- WinForms 界面保持不变。 + +完成门槛:现有界面和 CLI 行为完全等价。 + +### 阶段 2:Job 持久化与状态机 + +- 提取 `JobRepository`、`JobStateMachine` 和 `JobRecoveryService`。 +- 集中原子文件和路径约束。 +- 保持现有落盘格式,验证新版能恢复旧 Job。 +- 增加状态机和恢复测试。 + +完成门槛:旧 Job 样本可恢复,终态与 Workspace 不回退。 + +### 阶段 3:会话、协议与传输 + +- 拆分 `NodeSession`、`NodeProtocolCodec` 和 `NodeProtocolClient`。 +- 引入共享 `NodeHttpClient`。 +- 统一错误分类、认证注入和安全重试。 +- 由 `JobCoordinator` 接管消息业务编排。 + +完成门槛:假服务端重连、offer、cancel、上传重试和身份拒绝测试通过。 + +### 阶段 4:Adapter、进程与 runtime + +- 提取 `AdapterCatalog`、`AdapterExecutionService` 和 `ProcessSupervisor`。 +- 拆分软件位置、probe 和 runtime 安装。 +- 统一活动任务执行门和取消收尾。 +- 不改 Adapter 文件或合同。 + +完成门槛:Origin、ANSYS、Blender 现有 probe、执行、取消和打包测试通过。 + +### 阶段 5:WPF Shell 与托盘 + +- 启用 WPF,建立 App、MainWindow、资源字典和 ViewModel 基础设施。 +- 保留 WinForms NotifyIcon。 +- 完成无参数 GUI、CLI 和 headless 启动分流。 +- 先建立空页面与完整生命周期,不删除旧窗口。 + +完成门槛:单实例、后台启动、显示/隐藏和安全退出通过。 + +### 阶段 6:逐页迁移 + +按以下顺序迁移: + +1. 节点概览与首次注册。 +2. 运行设置与数据目录迁移。 +3. 专业软件卡和 runtime 管理。 +4. ANSYS 固定验收。 +5. 本机任务列表和详情。 + +完成门槛:每一页迁移后均完成行为等价检查,不等待最后一次性验收。 + +### 阶段 7:移除 WinForms 主窗口与加固 + +- 删除 `ConfigurationForm` 和旧 `TrayApplicationContext`。 +- 保留 NotifyIcon 所需的最小 WinForms 依赖。 +- 清理重复状态、静态 Service Locator 和临时兼容代码。 +- 完成本机结构化日志与诊断摘要。 +- 完成全量专项测试和发布包真机烟测。 + +完成门槛:源码中没有第二套可达 UI 或重复 Host 生命周期。 + +### 阶段 8:文档与发布准备 + +- 架构落地后把稳定决策同步到根 `DESIGN.md`。 +- 如运行方式、诊断路径或部署步骤有变化,同步 `RUN.md` 和 `windows-node/README.md`。 +- 阶段性成果更新 `PROGRESS.md`。 +- WPF 稳定前不提升版本号,不创建已发布 Changelog 版本。 +- 准备上线时再通过独立 release commit 更新版本和用户版 Changelog。 + +## 12. 建议提交拆分 + +每个提交只包含一个可验证的逻辑变化: + +1. `test(windows-node): characterize host lifecycle` +2. `refactor(windows-node): add shared composition root` +3. `refactor(windows-node): extract job state and persistence` +4. `refactor(windows-node): separate session and protocol handling` +5. `refactor(windows-node): unify job transfers` +6. `refactor(windows-node): extract adapter process supervision` +7. `refactor(windows-node): split software runtime services` +8. `feat(windows-node): add wpf shell and tray host` +9. `feat(windows-node): migrate overview and settings views` +10. `feat(windows-node): migrate software and acceptance views` +11. `feat(windows-node): migrate local job monitor` +12. `refactor(windows-node): remove legacy winforms window` +13. `test(windows-node): cover wpf and host integration` +14. `docs(windows-node): document host and wpf architecture` + +实施中可根据实际依赖合并相邻提交,但不得将 Host 重构、UI 视觉调整和发布版本提升混入同一提交。 + +## 13. 风险与控制 + +| 风险 | 控制措施 | +|---|---| +| 重连时重复启动任务 | Host 统一拥有会话代次和单槽执行门 | +| 退出后残留 Worker | 所有进程归 `ProcessSupervisor`,停止时等待完整树退出 | +| 上传重试导致重复执行 | 执行终态与传输状态分离,传输只重放幂等上传 | +| 后台线程更新 WPF 集合 | Snapshot 在 Dispatcher 上映射到 ObservableCollection | +| 新版无法恢复旧 Job | 不改落盘字段,使用历史样本做恢复测试 | +| 数据迁移期间重新接单 | Controller 先停调度并等待安全屏障,再执行迁移 | +| runtime 安装覆盖活动环境 | 本机执行门检查活动 Job,临时安装验证后原子替换 | +| Token 泄露到日志或 UI | 集中认证客户端、脱敏器和诊断字段 allowlist | +| WPF 与 WinForms 类型冲突 | WinForms 仅限 Tray 命名空间并使用完整类型名 | +| 重构范围失控 | 非目标列表和阶段完成门槛作为 review gate | + +## 14. 回退策略 + +- 整个改动在独立功能分支完成,WPF 稳定前不让线上用户接触。 +- 每个阶段保持可构建,Host 抽取阶段继续由旧 WinForms UI 驱动。 +- WPF 迁移期间旧窗口可作为开发对照,但最终合并前必须只有一套可达 UI。 +- 发布前保留上一版完整 Node 程序包;回退只替换程序目录,不修改 data root。 +- 因持久化格式保持兼容,回退旧 EXE 后仍能读取原有身份、Job 和 Workspace。 +- 若实施中确需新增持久化字段,必须做到旧版本忽略、新版本可缺省,且在本方案中补充独立兼容说明。 + +## 15. 完成定义 + +满足以下条件才视为重构完成: + +- WinForms `ConfigurationForm` 已移除,主界面全部使用 WPF。 +- WinForms 仅用于 NotifyIcon 或已明确记录的必要互操作。 +- GUI 和 headless 从同一个 Composition Root 获得 Node Host。 +- `NodeConnectionLoop` 的会话、协议和 Job 编排职责已拆分。 +- `JobInboxStore` 的持久化、恢复、状态机和 UI 展示职责已拆分。 +- `SoftwareRuntimeManager` 的定义、位置、probe 和安装职责已拆分。 +- 关键 Host 生命周期和 Job 状态转换有真实 .NET 行为测试。 +- 旧节点身份、历史 Job 和 Workspace 在新版测试样本中可继续使用。 +- Origin、ANSYS、Blender Adapter 无需修改即可运行。 +- framework-dependent 与 self-contained 发布包均通过构建和启动烟测。 +- 真机托盘、DPI、runtime 安装、任务执行、取消、上传和安全退出验收通过。 +- `DESIGN.md`、`RUN.md`、`PROGRESS.md` 和 Windows Node README 已按实际落地范围同步。 + +## 16. 首个实施切片 + +建议第一个开发切片只做以下内容: + +1. 增加 Host 生命周期 Characterization 测试。 +2. 引入 `NodeCompositionRoot`,消除 GUI 与 headless 的重复构造路径。 +3. 提取 `NodeApplicationController`,从托盘类移出注册、重连、迁移和退出编排。 +4. 让现有 WinForms UI 继续驱动新的 Controller。 +5. 完成 build、专项测试、打包和启动烟测。 + +该切片不启用 WPF、不移动 Job 格式、不改 Adapter,也不改变用户可见界面。它先建立后续 Host 重构和 WPF 迁移共同依赖的稳定边界,风险最低、验证价值最高。 + +### 16.1 实施记录(2026-08-31) + +首个切片已完成:新增 `Zcbot.WindowsNode.Tests` 与 Host 生命周期行为测试;引入唯一 `NodeCompositionRoot` 和 UI 无关的 `NodeApplicationController`;GUI 与 `run --headless` 已共用组装入口;注册、重连、清除身份、迁移和安全退出编排已从托盘类移入 Controller。现有 WinForms 主窗口、协议、Job/Workspace 落盘格式、Adapter 和发布目录均保持不变。 + +验证已覆盖未注册启动、重连收尾屏障、幂等停止、取消退出、迁移失败重启和清身份收尾;.NET 测试、既有 Windows Node 源码专项测试、solution build、framework-dependent 打包及 GUI/headless 启动烟测通过。下一切片进入阶段 2,提取 Job 持久化、状态机与恢复决策。 + +### 16.2 阶段 2 实施记录(2026-08-31) + +Job 持久化、状态和恢复边界已提取:`JobRepository` 直接负责既有 request/state/terminal/marker 目录的读取与原子写,`JobStateMachine` 约束正常单向转换并以显式 `Recovery` 模式兼容重启后的输入复验和历史成功任务续传,`JobRecoveryService` 将落盘事实分类为续执行、续上传、重放终态和无需动作。`AtomicFile` 与 `PathGuard` 集中保持同目录临时文件、flush 后替换和 Job 派生路径不越界;`JobInboxStore` 暂时保留 offer 校验、旧调用薄入口和 UI 展示读取,后续由协议与 `JobMonitorService` 切片继续收窄。 + +新增测试覆盖完整状态图、非法回退、终态不可重开、恢复例外、旧 request/state 样本无迁移读取、终态幂等、offer 先持久化及 digest/lease 重放、Workspace promotion/rollback、原子替换和路径穿越拒绝。40 项 .NET 行为测试、25 项既有源码专项、格式检查、Release 发布包及 headless 启动烟测通过;下一切片进入阶段 3,会话、协议与传输拆分。 + +### 16.3 阶段 3 实施记录(2026-08-31) + +会话、协议和 Job 编排已从原连接循环分离:`NodeSession` 负责连接、hello、心跳、单接收循环与退避重连,`NodeProtocolCodec` 保持既有消息信封并允许未知消息按原策略忽略,`NodeProtocolClient` 集中处理文本分片、1 MiB 上限、身份关闭语义和发送锁;`JobCoordinator` 接管 offer、cancel、export、恢复回报及本地执行流水线,新的 `NodeConnectionLoop` 只组合并收尾两者。 + +输入下载和输出上传现共用每份已加载配置唯一的 `NodeHttpClient`,统一 BaseAddress、Bearer、Node ID 与 User-Agent;lease/digest 保持逐请求 Header,避免共享 Header 竞争。`TransferRetryPolicy` 将认证、瞬时、永久和取消错误分开,只有无请求体的 GET 下载执行有界自动重试,输出上传继续依赖既有服务端幂等确认与落盘恢复,不会因传输重试重新执行专业软件。新增假 WebSocket/HTTP handler 测试覆盖信封兼容、未知消息、分片接收、并发发送、4003 身份拒绝、断线重连、认证不重试和瞬时下载重试;57 项 .NET 行为测试、25 项源码专项、格式与 diff 检查、Release 发布包及隔离数据目录 headless 烟测通过。下一切片进入阶段 4,拆分 Adapter、进程监督与 runtime。 + +### 16.4 阶段 4 实施记录(2026-08-31) + +Adapter 发现、合同、执行和进程边界已拆分:`AdapterCatalog` 以线程安全 Lazy 在启动期一次发现并缓存已安装描述符,`AdapterContractLoader` 负责 manifest 白名单、版本/runtime、相对路径边界、合同 capability、Workspace 输出、JSON Schema、入口扩展名和 SHA-256;`AdapterExecutionService` 保留既有 Job 目录协议、终态读取、Workspace promotion/restore 和错误码,所有 capability 共享 `JobExecutionGate`,不再只依赖云端 slot。`ProcessSupervisor` 统一固定进程启动、受限标准流采集、取消、完整进程树终止和 30 秒退出确认;对不存在 Job 的取消不再创建遗留 CTS。 + +软件与 runtime 已拆为 `SoftwareCatalog`、`SoftwareLocationService`、`SoftwareProbeService` 和 `ManagedRuntimeInstaller`。Origin/ANSYS/Blender 固定定义、兼容环境变量、注册表与标准目录检测顺序保持不变,probe 保持 30 秒缓存并可在 runtime 更新后显式失效;安装器仍只接受内置 requirements 和固定 Python 3.12 发现方式,在临时目录验证后原子替换并保留失败 rollback,同时在入口机械拒绝活动执行。新增行为测试覆盖已打包 Adapter 合同加载、跨 capability 单槽、等待取消、软件目录定义和 probe 缓存失效;62 项 .NET 行为测试、122 项 Origin/ANSYS/Blender/合同/Node 专项、格式与 diff 检查、Release 发布包及隔离数据目录 headless 烟测通过。下一切片进入阶段 5,建立 WPF Shell、ViewModel 基础设施及 NotifyIcon 互操作。 + +### 16.5 阶段 5 实施记录(2026-08-31) + +GUI 入口已切换为 WPF `Application` 消息循环,新增 `MainWindow`、Light 资源字典、`ObservableObject`、同步/异步 Command 和 `MainWindowViewModel`。主窗口提供节点状态总览、隐藏到托盘、安全退出及经典配置入口;关闭窗口只隐藏,不终止后台节点。新的 `TrayHost` 通过显式 WinForms 类型别名管理 `NotifyIcon`,把 Controller 状态切回 WPF Dispatcher,延续托盘双击、重连、三选一活动任务退出和取消二次确认。尚未迁移的注册、专业软件、验收、本机任务和数据目录功能仍由按需创建的 `ConfigurationForm` 承担,阶段 8 完成前保持可达。 + +CLI 参数分支仍在创建任何 WPF 对象前执行,`run --headless`、单实例互斥体、Composition Root 和 Controller 生命周期保持原语义。新增 ViewModel 状态映射、命令转发、异步命令防重入与异常报告测试;66 项 .NET 行为测试、188 项 Origin/ANSYS/Blender/合同/Node 专项、格式与 diff 检查、framework-dependent Release 包、隔离 GUI 消息循环及 headless 返回码烟测通过。下一切片进入阶段 6,迁移节点概览、注册与运行设置页面。 + +### 16.6 阶段 6 第一切片实施记录(2026-08-31) + +节点概览、首次注册和运行设置基础能力已迁入 WPF。`MainWindowViewModel` 现在持有页面导航、身份摘要、注册字段、操作反馈及 Command 可用状态,通过事件边界复用 Controller 的注册、重连和清身份流程;一次性注册码由 `PasswordBox` 同步到 ViewModel,注册成功或身份状态刷新后立即清空。清身份继续二次确认,注册继续使用 `EnrollOptions` 的 URL、名称和注册码校验。登录自启动经 `IStartupRegistration` 接口调用当前账号 `Run` 项,权限或 IO 失败会保留原开关状态并展示错误。运行设置展示实际 data root,迁移按钮暂时进入经典配置,以继续使用已有活动任务检查、固定磁盘校验、复制摘要和重启流程。 + +专业软件、runtime、ANSYS 验收、本机任务和数据目录迁移尚未迁入 WPF,因此本记录不把阶段 6 标为整体完成。新增测试覆盖 WPF 页面导航、身份映射、注册参数转发、自启动成功与失败回滚,源码契约确认 WPF 不直接依赖 Controller;69 项 .NET 行为测试、189 项 Origin/ANSYS/Blender/合同/Node 专项、格式与 diff 检查、framework-dependent Release 包及隔离 GUI/headless 烟测通过。下一切片迁移专业软件卡与 runtime 管理。 + +### 16.7 阶段 6 第二切片实施记录(2026-08-31) + +专业软件卡和 runtime 管理已迁入 WPF。新的 `SoftwareManagementService` 组合 `SoftwareCatalog`、`SoftwareLocationService`、Adapter probe 和 `ManagedRuntimeInstaller`,向 Presentation 只暴露不可变快照与受控操作;`SoftwarePageViewModel` 在用户进入页面后异步刷新 Origin、ANSYS、Blender 卡片,支持选择严格匹配的 EXE/安装根目录、清除账号级配置后恢复自动检测、安装或更新隔离 runtime,以及显式取消安装。文件和目录对话框、安装确认仍封装在 Tray WinForms 互操作层,WPF ViewModel 不读取注册表、不创建进程,也不接触任意命令。 + +软件快照直接读取 `AdapterCatalog.HasActiveExecution`,全机任一 Job 执行时均禁用 runtime 安装;即使 UI 快照过期,安装器入口仍会二次拒绝活动执行。安装继续固定 Python 3.12、内置 requirements 和镜像配置,在临时目录验证后原子切换,取消时终止完整安装进程树,失败保持原 runtime。新增测试覆盖软件卡刷新、位置保存与清除、安装确认/结果、活动任务禁用和取消收尾;73 项 .NET 行为测试、190 项 Origin/ANSYS/Blender/合同/Node 专项、格式与 diff 检查、framework-dependent Release 包及隔离 GUI/headless 烟测通过,未执行真实安装或启动专业软件。下一切片迁移 ANSYS 固定验收。 + +### 16.8 WPF UI 打磨记录(2026-08-31) + +在不改变页面命令和 Host 生命周期的前提下,Light 主题补齐集中颜色、表面、边框、状态和间距 token,并用 WPF ControlTemplate 统一按钮与侧栏导航的圆角、悬停、按下、禁用和键盘焦点状态。侧栏使用单选视觉的 ToggleButton 显示当前页,页头增加实时节点状态徽标,概览卡增加同源状态色带,软件卡增加位置可用性徽标和安装忙碌进度;操作区改用 WrapPanel,在 760 px 最小窗口下可自动换行。操作结果改为独立提示条,仅在有消息时占位,减少与底部常驻说明的竞争。 + +新增 UI 结构契约覆盖导航选中绑定、状态 tone、响应式操作区、忙碌反馈和主题资源,既有 ViewModel 测试补充状态色与提示可见性;73 项 .NET 行为测试、191 项 Origin/ANSYS/Blender/合同/Node/UI 专项、格式与 diff 检查、framework-dependent Release 包及隔离 GUI/headless 烟测通过。纯视觉改动未改变 CLI、托盘、注册、runtime 安装或持久化契约。 + +### 16.9 阶段 6 第三切片实施记录(2026-08-31) + +按主导航可用性优先,将本机任务迁移提前到 ANSYS 固定验收之前。`JobMonitorService` 从 `JobInboxStore` 接管面向 UI 的只读快照生成,继续合并 request、state、terminal、cloud-terminal 和 upload-complete 落盘事实,不改变既有 Job 目录或恢复协议。新的 `JobPageViewModel` 负责状态中文映射、执行时长、摘要、选中详情与按 Job ID 保持选择;WPF 页面以只读表格展示最近 50 条记录,进入页面立即刷新,并在窗口可见且任务页激活时每秒刷新。侧栏“本机任务”不再打开经典配置。 + +新增测试覆盖快照刷新、状态格式、读取失败、刷新后选择保持,以及真实 WPF 资源加载后的四页互斥可见性和标题绑定;94 项 .NET 行为测试、28 项 Windows Node 源码专项、格式与 diff 检查通过。阶段 6 尚余 ANSYS 固定验收和数据目录完整迁移,完成后再删除经典任务页及其 WinForms 定时器,避免迁移期间影响现有回退入口。 + +### 16.10 阶段 6 完成记录(2026-08-31) + +ANSYS 固定验收和数据目录完整迁移已进入 WPF。`AnsysAcceptanceService` 固定绑定已打包的 `ansys.mechanical.static_structural@v2` 验收入口,ViewModel 提供报告目录选择、长时进度、显式停止、通过报告校验和机器级执行门;执行门仍要求本次报告 `passed=true`、用户确认许可证已释放,并在权限不足时保持关闭。`DataRootPageViewModel` 通过 `DataRootManagementService` 检查环境变量托管与未完成任务,目标规范化和二次确认后只调用 Controller 的迁移编排,继续保持停连、复制、逐文件 SHA-256 校验、成功后保存新根目录与失败重连回滚。 + +### 16.11 阶段 7 完成记录(2026-08-31) + +诊断摘要提取为 `DiagnosticService` 并由 WPF 概览页复制,内容继续排除 Node Token。删除 `ConfigurationForm` 和旧 `TrayApplicationContext`,托盘菜单不再暴露经典配置入口;WinForms 仅保留 `NotifyIcon`、固定文件/目录选择、确认框和剪贴板互操作。Presentation 不直接持有 Controller,不解析 Job JSON,不访问注册表,也不创建 Worker;117 项 .NET 行为测试、28 项 Windows Node 源码专项、格式与 diff 检查通过。下一步进入阶段 8 发布准备与目标机 DPI/GUI 烟测。 diff --git a/windows-node/Zcbot.WindowsNode.Tests/AdapterArchitectureTests.cs b/windows-node/Zcbot.WindowsNode.Tests/AdapterArchitectureTests.cs new file mode 100644 index 0000000..783e0ea --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/AdapterArchitectureTests.cs @@ -0,0 +1,89 @@ +namespace Zcbot.WindowsNode.Tests; + +public sealed class AdapterArchitectureTests +{ + [Fact] + public void ContractLoaderReadsEveryPackagedAdapterWithUniqueCapabilities() + { + var descriptors = Directory.EnumerateDirectories(AdapterCatalog.AdapterRoot) + .Where(path => File.Exists(Path.Combine(path, "adapter.json"))) + .Select(AdapterContractLoader.Load) + .ToArray(); + + Assert.NotEmpty(descriptors); + Assert.Equal( + descriptors.Length, + descriptors.Select(item => item.Contract.Capability).Distinct().Count()); + Assert.All(descriptors, descriptor => + { + Assert.StartsWith(descriptor.DirectoryPath, descriptor.EntrypointPath); + Assert.StartsWith(descriptor.DirectoryPath, descriptor.ContractPath); + Assert.Matches("^[0-9a-f]{64}$", descriptor.ContractSha256); + }); + } + + [Fact] + public async Task ExecutionGateSerializesAllAdapterCapabilities() + { + using var gate = new JobExecutionGate(); + using var first = await gate.EnterAsync(CancellationToken.None); + var secondEntered = false; + var second = Task.Run(async () => + { + using var lease = await gate.EnterAsync(CancellationToken.None); + secondEntered = true; + }); + + await Task.Delay(30); + Assert.False(secondEntered); + Assert.True(gate.IsActive); + first.Dispose(); + await second; + Assert.True(secondEntered); + Assert.False(gate.IsActive); + } + + [Fact] + public async Task ExecutionGateHonorsCancellationWhileWaiting() + { + using var gate = new JobExecutionGate(); + using var first = await gate.EnterAsync(CancellationToken.None); + using var stop = new CancellationTokenSource(); + stop.Cancel(); + + await Assert.ThrowsAnyAsync( + async () => await gate.EnterAsync(stop.Token)); + } + + [Fact] + public void SoftwareCatalogKeepsThePublishedRuntimeIdentifiers() + { + Assert.Equal(["origin", "ansys", "blender"], + SoftwareCatalog.Definitions.Select(item => item.RuntimeId)); + Assert.Equal("ZCBOT_ORIGIN_EXE", + SoftwareCatalog.ByRuntimeId("origin").EnvironmentVariable); + Assert.Equal("AWP_ROOT242", + SoftwareCatalog.ByRuntimeId("ansys").EnvironmentVariable); + Assert.Equal("ZCBOT_BLENDER_EXE", + SoftwareCatalog.ByRuntimeId("blender").EnvironmentVariable); + } + + [Fact] + public void ProbeServiceCachesAndExplicitlyInvalidatesAProbe() + { + var calls = 0; + var service = new SoftwareProbeService(() => + { + calls++; + return new AdapterRuntimeStatus( + "Test", "1.0", "1.0.0", "ready", $"probe-{calls}"); + }); + + Assert.Equal("probe-1", service.Detect().Detail); + Assert.Equal("probe-1", service.Detect().Detail); + Assert.Equal(1, calls); + service.Invalidate(); + Assert.Equal("probe-2", service.Detect().Detail); + Assert.Equal(2, calls); + } +} diff --git a/windows-node/Zcbot.WindowsNode.Tests/AnsysAcceptanceViewModelTests.cs b/windows-node/Zcbot.WindowsNode.Tests/AnsysAcceptanceViewModelTests.cs new file mode 100644 index 0000000..986412c --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/AnsysAcceptanceViewModelTests.cs @@ -0,0 +1,109 @@ +namespace Zcbot.WindowsNode.Tests; + +public sealed class AnsysAcceptanceViewModelTests : IDisposable +{ + private readonly string root = Path.Combine( + Path.GetTempPath(), "zcbot-ansys-view-model-tests", Guid.NewGuid().ToString("N")); + + [Fact] + public async Task PassedAcceptanceEnablesTheMachineGateAfterConfirmation() + { + Directory.CreateDirectory(root); + var reportPath = Path.Combine(root, "acceptance-report.json"); + var service = new FakeAnsysAcceptanceService( + new AdapterAcceptanceResult(true, reportPath, "passed")); + using var viewModel = new AnsysAcceptanceViewModel(service); + viewModel.ChooseReportDirectoryRequested += () => root; + viewModel.ConfirmRunRequested += () => true; + viewModel.ConfirmEnableGateRequested += () => true; + + viewModel.RunCommand.Execute(null); + await WaitUntilAsync(() => !viewModel.IsRunning && service.RunCount == 1); + + Assert.True(viewModel.CanEnableGate); + Assert.Equal("success", viewModel.Tone); + Assert.Contains(reportPath, viewModel.Message); + + viewModel.EnableGateCommand.Execute(null); + + Assert.True(service.IsGateEnabled); + Assert.False(viewModel.CanEnableGate); + Assert.Contains("重新启动", viewModel.Message); + } + + [Fact] + public async Task CancellationKeepsTheExecutionGateClosed() + { + Directory.CreateDirectory(root); + var service = new FakeAnsysAcceptanceService(null) { BlockRun = true }; + using var viewModel = new AnsysAcceptanceViewModel(service); + viewModel.ChooseReportDirectoryRequested += () => root; + viewModel.ConfirmRunRequested += () => true; + + viewModel.RunCommand.Execute(null); + await service.RunStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + viewModel.CancelCommand.Execute(null); + await WaitUntilAsync(() => !viewModel.IsRunning); + + Assert.False(viewModel.CanEnableGate); + Assert.Equal("验收已停止,执行门保持关闭。", viewModel.Message); + } + + [Fact] + public void ReportValidationRequiresAnExplicitPassedValue() + { + Directory.CreateDirectory(root); + var passed = Path.Combine(root, "passed.json"); + var failed = Path.Combine(root, "failed.json"); + File.WriteAllText(passed, "{\"passed\":true}"); + File.WriteAllText(failed, "{\"passed\":false}"); + + Assert.True(AnsysAcceptanceService.ReportPassed(passed)); + Assert.False(AnsysAcceptanceService.ReportPassed(failed)); + Assert.False(AnsysAcceptanceService.ReportPassed(Path.Combine(root, "missing.json"))); + } + + public void Dispose() + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + + private static async Task WaitUntilAsync(Func condition) + { + var deadline = DateTime.UtcNow.AddSeconds(2); + while (!condition() && DateTime.UtcNow < deadline) + { + await Task.Delay(10); + } + Assert.True(condition()); + } + + private sealed class FakeAnsysAcceptanceService(AdapterAcceptanceResult? result) + : IAnsysAcceptanceService + { + internal bool BlockRun { get; init; } + internal int RunCount { get; private set; } + internal TaskCompletionSource RunStarted { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public bool IsAvailable => true; + public bool IsGateEnabled { get; private set; } + + public async Task RunAsync( + string workRoot, + IProgress progress, + CancellationToken cancellationToken) + { + RunCount++; + RunStarted.TrySetResult(); + progress.Report("running"); + if (BlockRun) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + return result ?? throw new InvalidOperationException("missing result"); + } + + public void EnableGate(string reportPath) => IsGateEnabled = true; + } +} diff --git a/windows-node/Zcbot.WindowsNode.Tests/DataRootPageViewModelTests.cs b/windows-node/Zcbot.WindowsNode.Tests/DataRootPageViewModelTests.cs new file mode 100644 index 0000000..6de6a1d --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/DataRootPageViewModelTests.cs @@ -0,0 +1,85 @@ +namespace Zcbot.WindowsNode.Tests; + +public sealed class DataRootPageViewModelTests +{ + [Fact] + public async Task ChangeMigratesConfirmedTargetAndReportsCompletion() + { + var service = new FakeDataRootManagementService(); + var viewModel = new DataRootPageViewModel(service); + NodeDataMigrationResult? completed = null; + viewModel.ChooseTargetRequested += _ => @"D:\zcbot-data"; + viewModel.ConfirmMigrationRequested += _ => true; + viewModel.MigrationRequested += target => Task.FromResult( + new NodeDataMigrationResult(@"C:\node-data", target, 3, 4096)); + viewModel.MigrationCompleted += result => completed = result; + + viewModel.ChangeCommand.Execute(null); + await WaitUntilAsync(() => completed is not null); + + Assert.Equal(@"D:\zcbot-data", completed!.TargetDirectory); + Assert.Contains("3 个文件", viewModel.Status); + Assert.True(viewModel.IsMigrating); + } + + [Fact] + public async Task ChangeRefusesPendingJobsBeforeChoosingATarget() + { + var service = new FakeDataRootManagementService { PendingJobs = true }; + var viewModel = new DataRootPageViewModel(service); + var chooseCount = 0; + viewModel.ChooseTargetRequested += _ => + { + chooseCount++; + return @"D:\zcbot-data"; + }; + viewModel.MigrationRequested += _ => throw new InvalidOperationException(); + + viewModel.ChangeCommand.Execute(null); + await WaitUntilAsync(() => viewModel.Status.Contains("未完成任务")); + + Assert.Equal(0, chooseCount); + Assert.False(viewModel.IsMigrating); + } + + [Fact] + public void EnvironmentManagedRootDisablesMigration() + { + var service = new FakeDataRootManagementService + { + Selection = new NodeDataRootSelection( + @"D:\managed", + IsEnvironmentManaged: true, + "environment"), + }; + var viewModel = new DataRootPageViewModel(service); + + Assert.False(viewModel.ChangeCommand.CanExecute(null)); + Assert.Contains(NodeDataRootSettings.EnvironmentVariableName, viewModel.Status); + } + + private static async Task WaitUntilAsync(Func condition) + { + var deadline = DateTime.UtcNow.AddSeconds(2); + while (!condition() && DateTime.UtcNow < deadline) + { + await Task.Delay(10); + } + Assert.True(condition()); + } + + private sealed class FakeDataRootManagementService : IDataRootManagementService + { + internal NodeDataRootSelection Selection { get; init; } = new( + @"C:\node-data", + IsEnvironmentManaged: false, + "default"); + internal bool PendingJobs { get; init; } + + public NodeDataRootSelection ReadSelection() => Selection; + + public bool HasPendingJobs => PendingJobs; + + public string Normalize(string path) => Path.TrimEndingDirectorySeparator(path); + } +} diff --git a/windows-node/Zcbot.WindowsNode.Tests/GlobalUsings.cs b/windows-node/Zcbot.WindowsNode.Tests/GlobalUsings.cs new file mode 100644 index 0000000..6811849 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using System.IO; +global using System.Net.Http; diff --git a/windows-node/Zcbot.WindowsNode.Tests/JobOfferPersistenceTests.cs b/windows-node/Zcbot.WindowsNode.Tests/JobOfferPersistenceTests.cs new file mode 100644 index 0000000..224d078 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/JobOfferPersistenceTests.cs @@ -0,0 +1,118 @@ +using System.Text.Json; + +namespace Zcbot.WindowsNode.Tests; + +public sealed class JobOfferPersistenceTests : IDisposable +{ + private readonly string root = Path.Combine( + Path.GetTempPath(), "zcbot-job-offer-tests", Guid.NewGuid().ToString("N")); + + [Fact] + public void AcceptedOfferIsDurableAndAConflictingDigestIsRejected() + { + var jobId = Guid.NewGuid(); + var leaseId = Guid.NewGuid(); + var digest = new string('d', 64); + var inbox = new JobInboxStore(root); + var adapters = new AdapterCatalog([new AcceptingAdapter()]); + + var accepted = inbox.Accept(Offer(jobId, leaseId, digest), adapters); + + Assert.True(accepted.Accepted); + var requestPath = Path.Combine( + root, jobId.ToString("D"), "request", "request.json"); + var statePath = Path.Combine(root, jobId.ToString("D"), "state.json"); + Assert.True(File.Exists(requestPath)); + Assert.True(File.Exists(statePath)); + using (var request = JsonDocument.Parse(File.ReadAllBytes(requestPath))) + { + Assert.Equal( + digest, + request.RootElement.GetProperty("request_digest").GetString()); + Assert.Equal( + leaseId, + request.RootElement.GetProperty("lease_id").GetGuid()); + } + using (var state = JsonDocument.Parse(File.ReadAllBytes(statePath))) + { + Assert.Equal("accepted", state.RootElement.GetProperty("stage").GetString()); + } + + var conflict = inbox.Accept( + Offer(jobId, Guid.NewGuid(), new string('e', 64)), adapters); + + Assert.False(conflict.Accepted); + Assert.Equal("job_digest_conflict", conflict.Reason); + } + + [Fact] + public void SameDigestWithANewLeaseUpdatesOnlyThePersistedOfferIdentity() + { + var jobId = Guid.NewGuid(); + var firstLease = Guid.NewGuid(); + var secondLease = Guid.NewGuid(); + var digest = new string('f', 64); + var inbox = new JobInboxStore(root); + var adapters = new AdapterCatalog([new AcceptingAdapter()]); + Assert.True(inbox.Accept(Offer(jobId, firstLease, digest), adapters).Accepted); + + var replay = inbox.Accept(Offer(jobId, secondLease, digest), adapters); + + Assert.True(replay.Accepted); + using var request = JsonDocument.Parse(File.ReadAllBytes(Path.Combine( + root, jobId.ToString("D"), "request", "request.json"))); + Assert.Equal(secondLease, request.RootElement.GetProperty("lease_id").GetGuid()); + Assert.Equal(digest, request.RootElement.GetProperty("request_digest").GetString()); + } + + public void Dispose() + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + + private static JsonElement Offer(Guid jobId, Guid leaseId, string digest) + { + var payload = JsonSerializer.SerializeToElement(new + { + job_id = jobId, + lease_id = leaseId, + request_digest = digest, + capability = "test.capability@v1", + request = new { inputs = Array.Empty() }, + workspace = (object?)null, + input_transfers = Array.Empty(), + }); + return payload; + } + + private sealed class AcceptingAdapter : INodeAdapter + { + public string Capability => "test.capability@v1"; + public string DisplayName => "Test"; + public string AdapterVersion => "1.0.0"; + public IReadOnlyList Features => []; + public bool HasActiveJobs => false; + public string RunningDetail => "running"; + public string? RuntimeId => null; + public bool SupportsLocalAcceptance => false; + public string RuntimePath => "test.exe"; + public string ContractPath => "contract.json"; + public string ContractSha256 => new('0', 64); + public string? WorkspaceStateFilename => null; + public IReadOnlyList PreviewOutputIds => []; + public AdapterRuntimeStatus DetectRuntime() => + new("Test", "1", AdapterVersion, "ready", "ready"); + public void InvalidateRuntime() { } + public bool ValidateRequest(JsonElement request) => true; + public Task RunAsync(RecoverableJob job) => Task.CompletedTask; + public void Cancel(Guid jobId) { } + public Task RunLocalAcceptanceAsync( + string workRoot, + IProgress progress, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } +} diff --git a/windows-node/Zcbot.WindowsNode.Tests/JobPageViewModelTests.cs b/windows-node/Zcbot.WindowsNode.Tests/JobPageViewModelTests.cs new file mode 100644 index 0000000..80f9485 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/JobPageViewModelTests.cs @@ -0,0 +1,88 @@ +namespace Zcbot.WindowsNode.Tests; + +public sealed class JobPageViewModelTests +{ + [Fact] + public void Refresh_PopulatesJobsFormatsStateAndSelectsTheFirstItem() + { + var active = Snapshot("software_running", "正在生成图表", minutesAgo: 2); + var succeeded = Snapshot("succeeded", "历史任务", minutesAgo: 10); + var viewModel = new JobPageViewModel(new FakeJobMonitorService(active, succeeded)); + + viewModel.Refresh(); + + Assert.Equal(2, viewModel.Jobs.Count); + Assert.Same(viewModel.Jobs[0], viewModel.SelectedJob); + Assert.Equal("软件执行中", viewModel.Jobs[0].StageText); + Assert.StartsWith("已执行 02:", viewModel.Jobs[0].ProgressText); + Assert.Equal("活动任务 1 个 · 最近记录 2 条", viewModel.Summary); + Assert.Contains(active.JobId.ToString(), viewModel.SelectedDetail); + } + + [Fact] + public void Refresh_PreservesSelectionByJobIdAcrossSnapshots() + { + var first = Snapshot("accepted", "第一项", minutesAgo: 3); + var second = Snapshot("failed", "第二项", minutesAgo: 2); + var service = new FakeJobMonitorService(first, second); + var viewModel = new JobPageViewModel(service); + viewModel.Refresh(); + viewModel.SelectedJob = viewModel.Jobs[1]; + + service.Snapshots = [ + second with { Detail = "更新后的错误" }, + first, + ]; + viewModel.Refresh(); + + Assert.Equal(second.JobId, viewModel.SelectedJob?.JobId); + Assert.Contains("更新后的错误", viewModel.SelectedDetail); + Assert.Equal("error", viewModel.SelectedJob?.Tone); + } + + [Fact] + public void Refresh_ReportsReadFailuresWithoutDroppingTheCurrentList() + { + var service = new FakeJobMonitorService(Snapshot("accepted", "任务", 1)); + var viewModel = new JobPageViewModel(service); + viewModel.Refresh(); + service.Failure = new IOException("locked"); + + viewModel.Refresh(); + + Assert.Single(viewModel.Jobs); + Assert.Contains("locked", viewModel.Summary); + } + + private static JobDisplaySnapshot Snapshot( + string stage, + string title, + int minutesAgo) + { + var updatedAt = DateTimeOffset.UtcNow.AddMinutes(-minutesAgo); + return new JobDisplaySnapshot( + Guid.NewGuid(), + "origin.plot@v2", + title, + "input.csv", + stage, + stage == "succeeded" ? 100 : 25, + title, + updatedAt.AddMinutes(-1), + updatedAt, + stage == "succeeded"); + } + + private sealed class FakeJobMonitorService(params JobDisplaySnapshot[] snapshots) + : IJobMonitorService + { + internal IReadOnlyList Snapshots { get; set; } = snapshots; + internal Exception? Failure { get; set; } + + public IReadOnlyList ReadSnapshots(int limit = 50) + { + if (Failure is not null) throw Failure; + return Snapshots; + } + } +} diff --git a/windows-node/Zcbot.WindowsNode.Tests/JobRecoveryServiceTests.cs b/windows-node/Zcbot.WindowsNode.Tests/JobRecoveryServiceTests.cs new file mode 100644 index 0000000..8cf908a --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/JobRecoveryServiceTests.cs @@ -0,0 +1,67 @@ +using System.Text.Json; + +namespace Zcbot.WindowsNode.Tests; + +public sealed class JobRecoveryServiceTests +{ + [Fact] + public void JobWithoutTerminalResumesExecution() + { + Assert.Equal( + JobRecoveryAction.ResumeExecution, + JobRecoveryService.Decide(CreateJob())); + } + + [Theory] + [InlineData("failed")] + [InlineData("cancelled")] + public void FailedOrCancelledTerminalIsReplayed(string status) + { + Assert.Equal( + JobRecoveryAction.ReplayTerminal, + JobRecoveryService.Decide(CreateJob(terminal: Terminal(status)))); + } + + [Fact] + public void UnconfirmedSuccessResumesOnlyOutputUpload() + { + Assert.Equal( + JobRecoveryAction.ResumeOutputUpload, + JobRecoveryService.Decide(CreateJob(terminal: Terminal("succeeded")))); + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public void ConfirmedOrCloudTerminatedSuccessNeedsNoRecovery( + bool uploadComplete, + bool cloudTerminal) + { + Assert.Equal( + JobRecoveryAction.None, + JobRecoveryService.Decide(CreateJob( + terminal: Terminal("succeeded"), + uploadComplete: uploadComplete, + cloudTerminal: cloudTerminal))); + } + + private static RecoverableJob CreateJob( + JsonElement? terminal = null, + bool uploadComplete = false, + bool cloudTerminal = false) => new( + Guid.NewGuid(), + Guid.NewGuid(), + new string('a', 64), + "origin.plot@v2", + null, + null, + terminal, + uploadComplete, + cloudTerminal); + + private static JsonElement Terminal(string status) + { + using var document = JsonDocument.Parse($$"""{"status":"{{status}}"}"""); + return document.RootElement.Clone(); + } +} diff --git a/windows-node/Zcbot.WindowsNode.Tests/JobRepositoryCompatibilityTests.cs b/windows-node/Zcbot.WindowsNode.Tests/JobRepositoryCompatibilityTests.cs new file mode 100644 index 0000000..bb7d852 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/JobRepositoryCompatibilityTests.cs @@ -0,0 +1,109 @@ +using System.Text.Json; + +namespace Zcbot.WindowsNode.Tests; + +public sealed class JobRepositoryCompatibilityTests : IDisposable +{ + private readonly string root = Path.Combine( + Path.GetTempPath(), "zcbot-job-repository-tests", Guid.NewGuid().ToString("N")); + + [Fact] + public void ReadsExistingRequestAndStateWithoutMigration() + { + var jobId = Guid.NewGuid(); + var leaseId = Guid.NewGuid(); + var directory = Path.Combine(root, jobId.ToString("D")); + Directory.CreateDirectory(Path.Combine(directory, "request")); + File.WriteAllText( + Path.Combine(directory, "request", "request.json"), + JsonSerializer.Serialize(new + { + job_id = jobId, + lease_id = leaseId, + request_digest = new string('b', 64), + capability = "origin.plot@v2", + accepted_at = "2026-08-20T01:02:03Z", + request = new { }, + workspace = (object?)null, + input_transfers = Array.Empty(), + })); + File.WriteAllText( + Path.Combine(directory, "state.json"), + """{"stage":"software_running","progress":10,"detail":"running","updated_at":"2026-08-20T01:03:00Z"}"""); + var monitor = new JobMonitorService(root); + var repository = new JobRepository(root); + + var recovered = Assert.Single(repository.ReadRecoverableJobs()); + var displayed = Assert.Single(monitor.ReadSnapshots()); + + Assert.Equal(jobId, recovered.JobId); + Assert.Equal(leaseId, recovered.LeaseId); + Assert.Equal("software_running", displayed.Stage); + Assert.False(recovered.UploadComplete); + } + + [Fact] + public void RecoveryTransitionPreservesTheExistingStateJsonShape() + { + var job = CreateJob(); + var directory = Path.Combine(root, job.JobId.ToString("D")); + Directory.CreateDirectory(directory); + File.WriteAllText( + Path.Combine(directory, "state.json"), + """{"stage":"software_running","progress":10,"detail":"running","updated_at":"2026-08-20T01:03:00Z"}"""); + var repository = new JobRepository(root); + + Assert.Throws(() => repository.WriteState( + job, "downloading_inputs", 0, "retry")); + repository.WriteState( + job, + "downloading_inputs", + 0, + "retry", + JobTransitionMode.Recovery); + + using var state = JsonDocument.Parse( + File.ReadAllBytes(Path.Combine(directory, "state.json"))); + Assert.Equal("downloading_inputs", state.RootElement.GetProperty("stage").GetString()); + Assert.Equal(0, state.RootElement.GetProperty("progress").GetInt32()); + Assert.Equal("retry", state.RootElement.GetProperty("detail").GetString()); + Assert.True(state.RootElement.TryGetProperty("updated_at", out _)); + Assert.Equal(4, state.RootElement.EnumerateObject().Count()); + } + + [Fact] + public void TerminalWriteRemainsIdempotent() + { + var job = CreateJob(); + var repository = new JobRepository(root); + + repository.WriteTerminal(job, "failed", "FIRST", "first failure"); + repository.WriteTerminal(job, "cancelled", "SECOND", "late cancellation"); + + using var terminal = JsonDocument.Parse(File.ReadAllBytes(Path.Combine( + root, job.JobId.ToString("D"), "terminal.json"))); + Assert.Equal("failed", terminal.RootElement.GetProperty("status").GetString()); + Assert.Equal( + "FIRST", + terminal.RootElement.GetProperty("error").GetProperty("code").GetString()); + } + + public void Dispose() + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + + private static RecoverableJob CreateJob() => new( + Guid.NewGuid(), + Guid.NewGuid(), + new string('c', 64), + "origin.plot@v2", + null, + null, + null, + false, + false); +} diff --git a/windows-node/Zcbot.WindowsNode.Tests/JobStateMachineTests.cs b/windows-node/Zcbot.WindowsNode.Tests/JobStateMachineTests.cs new file mode 100644 index 0000000..b43e6d9 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/JobStateMachineTests.cs @@ -0,0 +1,65 @@ +namespace Zcbot.WindowsNode.Tests; + +public sealed class JobStateMachineTests +{ + [Theory] + [InlineData("accepted", "downloading_inputs")] + [InlineData("downloading_inputs", "downloading_inputs")] + [InlineData("downloading_inputs", "ready_to_run")] + [InlineData("ready_to_run", "software_running")] + [InlineData("software_running", "uploading_outputs")] + [InlineData("uploading_outputs", "succeeded")] + [InlineData("accepted", "cancelled")] + [InlineData("software_running", "failed")] + public void NormalTransitionsAcceptThePersistedExecutionFlow(string current, string next) + { + JobStateMachine.EnsureTransition(current, next); + } + + [Theory] + [InlineData("ready_to_run", "downloading_inputs")] + [InlineData("software_running", "ready_to_run")] + [InlineData("succeeded", "uploading_outputs")] + [InlineData("failed", "failed")] + [InlineData("unknown", "accepted")] + public void NormalTransitionsRejectBackwardsTerminalAndUnknownChanges( + string current, + string next) + { + Assert.Throws( + () => JobStateMachine.EnsureTransition(current, next)); + } + + [Theory] + [InlineData("ready_to_run")] + [InlineData("software_running")] + [InlineData("uploading_outputs")] + public void RecoveryMayReenterIdempotentInputVerification(string current) + { + JobStateMachine.EnsureTransition( + current, + JobStateMachine.DownloadingInputs, + JobTransitionMode.Recovery); + } + + [Theory] + [InlineData("accepted")] + [InlineData("ready_to_run")] + [InlineData("software_running")] + public void RecoveryMayResumeUploadFromHistoricalLocalState(string current) + { + JobStateMachine.EnsureTransition( + current, + JobStateMachine.UploadingOutputs, + JobTransitionMode.Recovery); + } + + [Fact] + public void RecoveryCannotReopenATerminalStage() + { + Assert.Throws(() => JobStateMachine.EnsureTransition( + JobStateMachine.Cancelled, + JobStateMachine.DownloadingInputs, + JobTransitionMode.Recovery)); + } +} diff --git a/windows-node/Zcbot.WindowsNode.Tests/NodeApplicationControllerTests.cs b/windows-node/Zcbot.WindowsNode.Tests/NodeApplicationControllerTests.cs new file mode 100644 index 0000000..ae9a42a --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/NodeApplicationControllerTests.cs @@ -0,0 +1,174 @@ +namespace Zcbot.WindowsNode.Tests; + +public sealed class NodeApplicationControllerTests : IDisposable +{ + private readonly string root = Path.Combine( + Path.GetTempPath(), "zcbot-windows-node-tests", Guid.NewGuid().ToString("N")); + + [Fact] + public void StartWithoutIdentityRemainsNotRegistered() + { + var store = new FakeConfigStore(); + var connections = new List(); + using var controller = CreateController(store, connections); + + controller.Start(); + + Assert.Equal(NodeState.NotRegistered, controller.CurrentStatus.State); + Assert.Null(controller.CurrentConfig); + Assert.Empty(connections); + } + + [Fact] + public async Task ReconnectWaitsForOldSessionAndStartsOneReplacement() + { + var store = new FakeConfigStore { Config = CreateConfig() }; + var connections = new List(); + using var controller = CreateController(store, connections); + controller.Start(); + Assert.Single(connections); + + var reconnected = await controller.ReconnectAsync(); + + Assert.True(reconnected); + Assert.Equal(2, connections.Count); + Assert.True(connections[0].Completed); + Assert.False(connections[1].Completed); + await controller.StopAsync(NodeShutdownMode.WaitForJobs); + } + + [Fact] + public async Task CancelStopIsIdempotentAndCancelsJobsOnce() + { + var store = new FakeConfigStore { Config = CreateConfig() }; + var connections = new List(); + using var controller = CreateController(store, connections); + controller.Start(); + + var first = controller.StopAsync(NodeShutdownMode.CancelJobs); + var second = controller.StopAsync(NodeShutdownMode.WaitForJobs); + await Task.WhenAll(first, second); + + Assert.Same(first, second); + Assert.Equal(1, connections[0].CancelJobsCalls); + Assert.True(connections[0].Completed); + Assert.Equal(NodeState.Stopped, controller.CurrentStatus.State); + } + + [Fact] + public async Task FailedMigrationRestartsTheExistingConfiguration() + { + var store = new FakeConfigStore { Config = CreateConfig() }; + var connections = new List(); + using var controller = CreateController( + store, + connections, + migrate: (_, _, _) => throw new IOException("copy failed")); + controller.Start(); + + await Assert.ThrowsAsync( + () => controller.MigrateDataRootAsync(Path.Combine(root, "new-root"))); + + Assert.Equal(2, connections.Count); + Assert.True(connections[0].Completed); + Assert.False(connections[1].Completed); + await controller.StopAsync(NodeShutdownMode.WaitForJobs); + } + + [Fact] + public async Task ResetIdentityWaitsForSessionBeforeDeletingIdentity() + { + var store = new FakeConfigStore { Config = CreateConfig() }; + var connections = new List(); + using var controller = CreateController(store, connections); + controller.Start(); + + await controller.ResetIdentityAsync(); + + Assert.True(connections[0].Completed); + Assert.True(store.IdentityDeleted); + Assert.Null(controller.CurrentConfig); + Assert.Equal(NodeState.NotRegistered, controller.CurrentStatus.State); + } + + public void Dispose() + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + + private NodeApplicationController CreateController( + FakeConfigStore store, + List connections, + Func>? migrate = null) + { + var paths = new NodePaths( + root, + Path.Combine(root, "node.json"), + Path.Combine(root, "jobs"), + Path.Combine(root, "workspaces")); + return new NodeApplicationController( + store, + paths, + (_, _) => + { + var connection = new FakeConnection(); + connections.Add(connection); + return connection; + }, + (_, _, _) => Task.CompletedTask, + migrate ?? ((source, target, _) => Task.FromResult( + new NodeDataMigrationResult(source, target, 0, 0))), + _ => { }); + } + + private static NodeConfig CreateConfig() => new( + new Uri("https://zcbot.example/"), + Guid.NewGuid(), + Guid.NewGuid(), + "test-node", + "secret", + 30, + []); + + private sealed class FakeConfigStore : INodeConfigStore + { + public NodeConfig? Config { get; set; } + public bool IdentityDeleted { get; private set; } + public bool Exists => Config is not null; + public string ConfigPath => "node.json"; + + public void Save(NodeConfig config) => Config = config; + + public NodeConfig Load() => Config + ?? throw new NodeConfigurationException("missing identity"); + + public void DeleteLocalIdentity() + { + IdentityDeleted = true; + Config = null; + } + } + + private sealed class FakeConnection : INodeConnection + { + public int CancelJobsCalls { get; private set; } + public bool Completed { get; private set; } + + public async Task RunAsync(CancellationToken cancellationToken) + { + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + finally + { + Completed = true; + } + } + + public void CancelActiveJobsForExit() => CancelJobsCalls++; + } +} diff --git a/windows-node/Zcbot.WindowsNode.Tests/NodeProtocolClientTests.cs b/windows-node/Zcbot.WindowsNode.Tests/NodeProtocolClientTests.cs new file mode 100644 index 0000000..24b3d67 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/NodeProtocolClientTests.cs @@ -0,0 +1,178 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Text; + +namespace Zcbot.WindowsNode.Tests; + +public sealed class NodeProtocolClientTests +{ + [Fact] + public async Task SendAsyncSerializesConcurrentWriters() + { + var socket = new TestWebSocket(sendDelay: TimeSpan.FromMilliseconds(15)); + await using var client = new NodeProtocolClient(socket); + + await Task.WhenAll(Enumerable.Range(0, 8).Select(index => + client.SendAsync("job_state", new { index }, CancellationToken.None))); + + Assert.Equal(1, socket.MaximumConcurrentSends); + Assert.Equal(8, socket.SentMessages.Count); + } + + [Fact] + public async Task ReceiveAsyncReassemblesTextFragments() + { + var socket = new TestWebSocket(); + socket.EnqueueText("{\"type\":\"job_", endOfMessage: false); + socket.EnqueueText("cancel\",\"payload\":{\"job_id\":\"abc\"}}", endOfMessage: true); + await using var client = new NodeProtocolClient(socket); + + var message = await client.ReceiveAsync(CancellationToken.None); + + Assert.NotNull(message); + Assert.Equal("job_cancel", message.Type); + Assert.Equal("abc", message.Payload.GetProperty("job_id").GetString()); + } + + [Fact] + public async Task ReceiveAsyncTurnsIdentityRejectionIntoConfigurationFailure() + { + var socket = new TestWebSocket(); + socket.EnqueueClose((WebSocketCloseStatus)4003, "disabled"); + await using var client = new NodeProtocolClient(socket); + + var exception = await Assert.ThrowsAsync( + () => client.ReceiveAsync(CancellationToken.None)); + + Assert.Contains("节点身份已被服务端拒绝", exception.Message); + Assert.Contains("disabled", exception.Message); + } + + private sealed class TestWebSocket : WebSocket + { + private readonly ConcurrentQueue receiveFrames = new(); + private readonly TimeSpan sendDelay; + private int activeSends; + private int maximumConcurrentSends; + private WebSocketCloseStatus? closeStatus; + private string? closeStatusDescription; + private WebSocketState state = WebSocketState.Open; + + internal TestWebSocket(TimeSpan sendDelay = default) + { + this.sendDelay = sendDelay; + } + + internal ConcurrentQueue SentMessages { get; } = new(); + + internal int MaximumConcurrentSends => maximumConcurrentSends; + + public override WebSocketCloseStatus? CloseStatus => closeStatus; + + public override string? CloseStatusDescription => closeStatusDescription; + + public override WebSocketState State => state; + + public override string? SubProtocol => null; + + internal void EnqueueText(string value, bool endOfMessage) + { + receiveFrames.Enqueue(new ReceiveFrame( + Encoding.UTF8.GetBytes(value), + WebSocketMessageType.Text, + endOfMessage, + null, + null)); + } + + internal void EnqueueClose(WebSocketCloseStatus status, string description) + { + receiveFrames.Enqueue(new ReceiveFrame( + [], WebSocketMessageType.Close, true, status, description)); + } + + public override void Abort() => state = WebSocketState.Aborted; + + public override Task CloseAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) + { + state = WebSocketState.Closed; + return Task.CompletedTask; + } + + public override Task CloseOutputAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) + { + state = WebSocketState.CloseSent; + return Task.CompletedTask; + } + + public override void Dispose() => state = WebSocketState.Closed; + + public override Task ReceiveAsync( + ArraySegment buffer, + CancellationToken cancellationToken) + { + if (!receiveFrames.TryDequeue(out var frame)) + { + throw new InvalidOperationException("No receive frame was queued."); + } + Array.Copy(frame.Bytes, 0, buffer.Array!, buffer.Offset, frame.Bytes.Length); + closeStatus = frame.CloseStatus; + closeStatusDescription = frame.CloseDescription; + return Task.FromResult(new WebSocketReceiveResult( + frame.Bytes.Length, + frame.MessageType, + frame.EndOfMessage, + frame.CloseStatus, + frame.CloseDescription)); + } + + public override async Task SendAsync( + ArraySegment buffer, + WebSocketMessageType messageType, + bool endOfMessage, + CancellationToken cancellationToken) + { + var current = Interlocked.Increment(ref activeSends); + InterlockedExtensions.Max(ref maximumConcurrentSends, current); + try + { + if (sendDelay > TimeSpan.Zero) + { + await Task.Delay(sendDelay, cancellationToken); + } + SentMessages.Enqueue(buffer.ToArray()); + } + finally + { + Interlocked.Decrement(ref activeSends); + } + } + + private sealed record ReceiveFrame( + byte[] Bytes, + WebSocketMessageType MessageType, + bool EndOfMessage, + WebSocketCloseStatus? CloseStatus, + string? CloseDescription); + } + + private static class InterlockedExtensions + { + internal static void Max(ref int target, int value) + { + var current = Volatile.Read(ref target); + while (current < value) + { + var previous = Interlocked.CompareExchange(ref target, value, current); + if (previous == current) return; + current = previous; + } + } + } +} diff --git a/windows-node/Zcbot.WindowsNode.Tests/NodeProtocolCodecTests.cs b/windows-node/Zcbot.WindowsNode.Tests/NodeProtocolCodecTests.cs new file mode 100644 index 0000000..702e56f --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/NodeProtocolCodecTests.cs @@ -0,0 +1,44 @@ +using System.Text.Json; + +namespace Zcbot.WindowsNode.Tests; + +public sealed class NodeProtocolCodecTests +{ + [Fact] + public void EncodeKeepsThePublishedEnvelopeShape() + { + var encoded = NodeProtocolCodec.Encode("heartbeat", new { available_slots = 1 }); + + using var document = JsonDocument.Parse(encoded); + var root = document.RootElement; + Assert.Equal(1, root.GetProperty("protocol_version").GetInt32()); + Assert.True(Guid.TryParse(root.GetProperty("message_id").GetString(), out _)); + Assert.Equal("heartbeat", root.GetProperty("type").GetString()); + Assert.True(DateTimeOffset.TryParse(root.GetProperty("sent_at").GetString(), out _)); + Assert.Equal(1, root.GetProperty("payload").GetProperty("available_slots").GetInt32()); + } + + [Fact] + public void DecodePreservesUnknownMessagesForTheBusinessDispatcher() + { + var message = NodeProtocolCodec.Decode( + """{"type":"future_message","payload":{"value":42}}"""u8.ToArray()); + + Assert.NotNull(message); + Assert.Equal("future_message", message.Type); + Assert.Equal(42, message.Payload.GetProperty("value").GetInt32()); + } + + [Fact] + public void DecodeIgnoresAnEnvelopeWithoutAStringType() + { + Assert.Null(NodeProtocolCodec.Decode("""{"payload":{}}"""u8.ToArray())); + Assert.Null(NodeProtocolCodec.Decode("""{"type":2,"payload":{}}"""u8.ToArray())); + } + + [Fact] + public void DecodeRejectsInvalidJson() + { + Assert.ThrowsAny(() => NodeProtocolCodec.Decode("{"u8.ToArray())); + } +} diff --git a/windows-node/Zcbot.WindowsNode.Tests/NodeSessionTests.cs b/windows-node/Zcbot.WindowsNode.Tests/NodeSessionTests.cs new file mode 100644 index 0000000..f0a5fc0 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/NodeSessionTests.cs @@ -0,0 +1,108 @@ +using System.Net.WebSockets; + +namespace Zcbot.WindowsNode.Tests; + +public sealed class NodeSessionTests +{ + [Fact] + public async Task SessionReconnectsAfterNetworkFailureAndSendsHelloBeforeRecovery() + { + var config = new NodeConfig( + new Uri("https://node.example/"), + Guid.NewGuid(), + Guid.NewGuid(), + "test-node", + "secret", + 30, + []); + var socket = new ClosingWebSocket(); + var attempts = 0; + var events = new List(); + using var stop = new CancellationTokenSource(); + var session = new NodeSession( + config, + () => new { available_slots = 0 }, + (client, cancellationToken) => + { + events.Add("recovery"); + stop.Cancel(); + return Task.CompletedTask; + }, + (_, _) => Task.CompletedTask, + (_, _, _) => Task.CompletedTask, + (state, _) => events.Add(state.ToString()), + (_, _) => + { + attempts++; + return attempts == 1 + ? Task.FromException(new WebSocketException("offline")) + : Task.FromResult(new NodeProtocolClient(socket)); + }, + (_, _) => Task.CompletedTask); + + await session.RunAsync(stop.Token); + + Assert.Equal(2, attempts); + Assert.Equal(["Connecting", "Offline", "Connecting", "Online", "recovery"], events); + Assert.Single(socket.SentTypes); + Assert.Equal("hello", socket.SentTypes[0]); + } + + private sealed class ClosingWebSocket : WebSocket + { + private WebSocketState state = WebSocketState.Open; + + internal List SentTypes { get; } = []; + + public override WebSocketCloseStatus? CloseStatus => WebSocketCloseStatus.NormalClosure; + + public override string? CloseStatusDescription => "done"; + + public override WebSocketState State => state; + + public override string? SubProtocol => null; + + public override void Abort() => state = WebSocketState.Aborted; + + public override Task CloseAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) + { + state = WebSocketState.Closed; + return Task.CompletedTask; + } + + public override Task CloseOutputAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) + { + state = WebSocketState.CloseSent; + return Task.CompletedTask; + } + + public override void Dispose() => state = WebSocketState.Closed; + + public override Task ReceiveAsync( + ArraySegment buffer, + CancellationToken cancellationToken) => Task.FromResult( + new WebSocketReceiveResult( + 0, + WebSocketMessageType.Close, + true, + WebSocketCloseStatus.NormalClosure, + "done")); + + public override Task SendAsync( + ArraySegment buffer, + WebSocketMessageType messageType, + bool endOfMessage, + CancellationToken cancellationToken) + { + var message = NodeProtocolCodec.Decode(buffer.ToArray()); + SentTypes.Add(message!.Type); + return Task.CompletedTask; + } + } +} diff --git a/windows-node/Zcbot.WindowsNode.Tests/PersistenceSafetyTests.cs b/windows-node/Zcbot.WindowsNode.Tests/PersistenceSafetyTests.cs new file mode 100644 index 0000000..9237339 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/PersistenceSafetyTests.cs @@ -0,0 +1,40 @@ +namespace Zcbot.WindowsNode.Tests; + +public sealed class PersistenceSafetyTests : IDisposable +{ + private readonly string root = Path.Combine( + Path.GetTempPath(), "zcbot-persistence-tests", Guid.NewGuid().ToString("N")); + + [Fact] + public void PathGuardAcceptsAChildAndRejectsTraversalOrRootedSegments() + { + var child = PathGuard.CombineUnderRoot(root, "jobs", "job-id", "state.json"); + + Assert.StartsWith(Path.GetFullPath(root), child, StringComparison.OrdinalIgnoreCase); + Assert.Throws( + () => PathGuard.CombineUnderRoot(root, "..", "outside.json")); + Assert.Throws( + () => PathGuard.CombineUnderRoot(root, Path.GetPathRoot(root)!)); + } + + [Fact] + public void AtomicFileReplacesTheTargetAndLeavesNoTemporaryFile() + { + var path = Path.Combine(root, "jobs", "state.json"); + + AtomicFile.Write(path, [1, 2, 3], overwrite: false); + AtomicFile.Write(path, [4, 5], overwrite: true); + + Assert.Equal([4, 5], File.ReadAllBytes(path)); + Assert.Empty(Directory.EnumerateFiles( + Path.GetDirectoryName(path)!, "state.json.tmp-*")); + } + + public void Dispose() + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } +} diff --git a/windows-node/Zcbot.WindowsNode.Tests/PresentationCommandTests.cs b/windows-node/Zcbot.WindowsNode.Tests/PresentationCommandTests.cs new file mode 100644 index 0000000..6bbdb82 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/PresentationCommandTests.cs @@ -0,0 +1,379 @@ +using System.Reflection; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; + +namespace Zcbot.WindowsNode.Tests; + +public sealed class PresentationCommandTests +{ + [Fact] + public void MainWindow_BindingsKeepOnlyTheSelectedPageVisible() + { + Exception? failure = null; + var thread = new Thread(() => + { + try + { + var application = new System.Windows.Application + { + ShutdownMode = ShutdownMode.OnExplicitShutdown, + }; + application.Resources.MergedDictionaries.Add(new ResourceDictionary + { + Source = new Uri( + "pack://application:,,,/Zcbot.WindowsNode;component/Presentation/Themes/LightTheme.xaml", + UriKind.Absolute), + }); + using var viewModel = CreateViewModel(); + var window = new MainWindow(viewModel); + window.Measure(new Size(1040, 720)); + window.Arrange(new Rect(0, 0, 1040, 720)); + window.Dispatcher.Invoke(() => { }, DispatcherPriority.DataBind); + + Assert.Equal(Visibility.Visible, FindPage(window, "OverviewPage").Visibility); + Assert.Equal(Visibility.Collapsed, FindPage(window, "SoftwarePage").Visibility); + Assert.Equal(Visibility.Collapsed, FindPage(window, "JobsPage").Visibility); + Assert.Equal(Visibility.Collapsed, FindPage(window, "SettingsPage").Visibility); + + viewModel.ShowJobsCommand.Execute(null); + window.Dispatcher.Invoke(() => { }, DispatcherPriority.DataBind); + + Assert.Equal(Visibility.Collapsed, FindPage(window, "OverviewPage").Visibility); + Assert.Equal(Visibility.Collapsed, FindPage(window, "SoftwarePage").Visibility); + Assert.Equal(Visibility.Visible, FindPage(window, "JobsPage").Visibility); + Assert.Equal(Visibility.Collapsed, FindPage(window, "SettingsPage").Visibility); + Assert.Equal( + "本机任务", + FindElement(window, "PageTitleText").Text); + + viewModel.ShowSettingsCommand.Execute(null); + window.Dispatcher.Invoke(() => { }, DispatcherPriority.DataBind); + + Assert.Equal(Visibility.Collapsed, FindPage(window, "OverviewPage").Visibility); + Assert.Equal(Visibility.Collapsed, FindPage(window, "SoftwarePage").Visibility); + Assert.Equal(Visibility.Collapsed, FindPage(window, "JobsPage").Visibility); + Assert.Equal(Visibility.Visible, FindPage(window, "SettingsPage").Visibility); + Assert.Equal( + "运行设置", + FindElement(window, "PageTitleText").Text); + Assert.Equal( + @"C:\node-data", + FindElement(window, "DataRootText").Text); + } + catch (Exception exception) + { + failure = exception; + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + + Assert.True(thread.Join(TimeSpan.FromSeconds(5)), "WPF binding smoke test timed out."); + if (failure is not null) throw failure; + } + + [Theory] + [InlineData(typeof(MainWindowViewModel), "PageTitle")] + [InlineData(typeof(MainWindowViewModel), "StatusText")] + [InlineData(typeof(MainWindowViewModel), "IsOverviewPage")] + [InlineData(typeof(MainWindowViewModel), "IsSoftwarePage")] + [InlineData(typeof(MainWindowViewModel), "IsJobsPage")] + [InlineData(typeof(MainWindowViewModel), "IsSettingsPage")] + [InlineData(typeof(MainWindowViewModel), "ShowOverviewCommand")] + [InlineData(typeof(MainWindowViewModel), "ShowSoftwareCommand")] + [InlineData(typeof(MainWindowViewModel), "ShowJobsCommand")] + [InlineData(typeof(MainWindowViewModel), "ShowSettingsCommand")] + [InlineData(typeof(MainWindowViewModel), "SoftwarePage")] + [InlineData(typeof(MainWindowViewModel), "JobPage")] + [InlineData(typeof(MainWindowViewModel), "AcceptancePage")] + [InlineData(typeof(MainWindowViewModel), "DataRootPage")] + [InlineData(typeof(MainWindowViewModel), "CopyDiagnosticsCommand")] + [InlineData(typeof(SoftwarePageViewModel), "Cards")] + [InlineData(typeof(SoftwarePageViewModel), "Message")] + [InlineData(typeof(SoftwareCardViewModel), "DisplayName")] + [InlineData(typeof(SoftwareCardViewModel), "LocationText")] + [InlineData(typeof(SoftwareCardViewModel), "InstallRuntimeCommand")] + [InlineData(typeof(JobPageViewModel), "Jobs")] + [InlineData(typeof(JobPageViewModel), "SelectedJob")] + [InlineData(typeof(JobPageViewModel), "Summary")] + [InlineData(typeof(JobPageViewModel), "RefreshCommand")] + [InlineData(typeof(LocalJobItemViewModel), "StageText")] + [InlineData(typeof(LocalJobItemViewModel), "DetailText")] + [InlineData(typeof(AnsysAcceptanceViewModel), "IsAvailable")] + [InlineData(typeof(AnsysAcceptanceViewModel), "Message")] + [InlineData(typeof(AnsysAcceptanceViewModel), "RunCommand")] + [InlineData(typeof(AnsysAcceptanceViewModel), "EnableGateCommand")] + [InlineData(typeof(DataRootPageViewModel), "CurrentRoot")] + [InlineData(typeof(DataRootPageViewModel), "Status")] + [InlineData(typeof(DataRootPageViewModel), "ChangeCommand")] + [InlineData(typeof(DataRootPageViewModel), "OpenCommand")] + public void WpfBindingSources_ArePublicProperties(Type type, string propertyName) + { + var property = type.GetProperty(propertyName); + + Assert.NotNull(property); + Assert.True(property!.GetMethod?.IsPublic); + } + + [Fact] + public void MainWindowViewModel_MapsStatusAndDisablesExitDuringShutdown() + { + var viewModel = CreateViewModel(); + + var config = new NodeConfig( + new Uri("https://zcbot.example/"), + Guid.NewGuid(), + Guid.NewGuid(), + "lab-node", + "secret", + 30, + ["origin.plot@v2"]); + viewModel.ApplyStatus(NodeStatus.Create(NodeState.Online, "已连接"), config); + + Assert.Equal("节点在线", viewModel.StatusText); + Assert.Equal("success", viewModel.StatusTone); + Assert.StartsWith("已连接", viewModel.StatusDetail); + Assert.True(viewModel.IsRegistered); + Assert.Contains("lab-node", viewModel.IdentityText); + Assert.True(viewModel.HideCommand.CanExecute(null)); + Assert.True(viewModel.ExitCommand.CanExecute(null)); + + viewModel.BeginExit(); + + Assert.True(viewModel.IsExiting); + Assert.Equal("正在退出", viewModel.StatusText); + Assert.Equal("neutral", viewModel.StatusTone); + Assert.False(viewModel.HideCommand.CanExecute(null)); + Assert.False(viewModel.ExitCommand.CanExecute(null)); + } + + [Fact] + public void MainWindowViewModel_ForwardsShellCommands() + { + var viewModel = CreateViewModel(); + var copied = false; + var hidden = false; + viewModel.ApplyStatus(NodeStatus.Create(NodeState.Online, "online"), CreateConfig()); + viewModel.CopyDiagnosticsRequested += text => copied = text == "diagnostic"; + viewModel.HideRequested += () => hidden = true; + + viewModel.CopyDiagnosticsCommand.Execute(null); + viewModel.HideCommand.Execute(null); + + Assert.True(copied); + Assert.True(hidden); + } + + [Fact] + public void MainWindowViewModel_NavigatesBetweenMigratedPages() + { + var viewModel = CreateViewModel(); + + viewModel.ShowSettingsCommand.Execute(null); + + Assert.True(viewModel.IsSettingsPage); + Assert.Equal("运行设置", viewModel.PageTitle); + + viewModel.ShowJobsCommand.Execute(null); + + Assert.True(viewModel.IsJobsPage); + Assert.Equal("本机任务", viewModel.PageTitle); + + viewModel.ShowOverviewCommand.Execute(null); + + Assert.True(viewModel.IsOverviewPage); + Assert.Equal("节点概览", viewModel.PageTitle); + } + + [Fact] + public async Task MainWindowViewModel_ValidatesAndForwardsRegistration() + { + var viewModel = CreateViewModel(); + var received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + viewModel.RegisterRequested += options => + { + received.SetResult(options); + return Task.CompletedTask; + }; + viewModel.ServerUrl = "https://zcbot.example/base"; + viewModel.NodeName = "lab-node"; + viewModel.EnrollmentCode = "ZCN-123456789012"; + + Assert.True(viewModel.RegisterCommand.CanExecute(null)); + viewModel.RegisterCommand.Execute(null); + + var options = await received.Task.WaitAsync(TimeSpan.FromSeconds(2)); + await WaitUntilAsync(() => !viewModel.IsBusy); + Assert.Equal("https://zcbot.example/base/", options.ServerUrl.AbsoluteUri); + Assert.Equal("lab-node", options.NodeName); + Assert.Equal("ZCN-123456789012", options.EnrollmentCode); + Assert.Equal("注册成功,正在连接节点", viewModel.OperationMessage); + } + + [Fact] + public void MainWindowViewModel_AppliesAndRollsBackStartupSetting() + { + var startup = new FakeStartupRegistration(); + var viewModel = CreateViewModel(startup); + + viewModel.IsStartupEnabled = true; + + Assert.True(startup.IsEnabled); + Assert.True(viewModel.IsStartupEnabled); + Assert.True(viewModel.HasOperationMessage); + + startup.Failure = new UnauthorizedAccessException("denied"); + viewModel.IsStartupEnabled = false; + + Assert.True(viewModel.IsStartupEnabled); + Assert.Contains("denied", viewModel.OperationMessage); + } + + [Fact] + public async Task AsyncCommand_PreventsReentryAndRestoresAvailability() + { + var release = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var finished = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var callCount = 0; + var command = new AsyncCommand(async () => + { + callCount++; + await release.Task; + finished.SetResult(); + }); + + command.Execute(null); + command.Execute(null); + + Assert.Equal(1, callCount); + Assert.True(command.IsRunning); + Assert.False(command.CanExecute(null)); + + release.SetResult(); + await finished.Task.WaitAsync(TimeSpan.FromSeconds(2)); + await WaitUntilAsync(() => !command.IsRunning); + + Assert.True(command.CanExecute(null)); + } + + [Fact] + public async Task AsyncCommand_ReportsExecutionFailure() + { + var failure = new InvalidOperationException("failed"); + var reported = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var command = new AsyncCommand(() => Task.FromException(failure)); + command.Failed += reported.SetResult; + + command.Execute(null); + + Assert.Same(failure, await reported.Task.WaitAsync(TimeSpan.FromSeconds(2))); + await WaitUntilAsync(() => !command.IsRunning); + Assert.True(command.CanExecute(null)); + } + + private static async Task WaitUntilAsync(Func condition) + { + var deadline = DateTime.UtcNow.AddSeconds(2); + while (!condition() && DateTime.UtcNow < deadline) + { + await Task.Delay(10); + } + + Assert.True(condition()); + } + + private static FrameworkElement FindPage(MainWindow window, string name) => + FindElement(window, name); + + private static T FindElement(MainWindow window, string name) + where T : FrameworkElement => + Assert.IsAssignableFrom(window.FindName(name)); + + private static MainWindowViewModel CreateViewModel( + FakeStartupRegistration? startup = null) => + new( + startup ?? new FakeStartupRegistration(), + new FakeDiagnosticService(), + new SoftwarePageViewModel(new EmptySoftwareManagementService()), + new JobPageViewModel(new EmptyJobMonitorService()), + new AnsysAcceptanceViewModel(new EmptyAnsysAcceptanceService()), + new DataRootPageViewModel(new EmptyDataRootManagementService())); + + private static NodeConfig CreateConfig() => new( + new Uri("https://zcbot.example/"), + Guid.NewGuid(), + Guid.NewGuid(), + "lab-node", + "secret", + 30, + ["origin.plot@v2"]); + + private sealed class FakeStartupRegistration : IStartupRegistration + { + internal Exception? Failure { get; set; } + + public bool IsEnabled { get; private set; } + + public void SetEnabled(bool enabled) + { + if (Failure is not null) throw Failure; + IsEnabled = enabled; + } + } + + private sealed class FakeDiagnosticService : IDiagnosticService + { + public string Build(NodeConfig config) => "diagnostic"; + } + + private sealed class EmptySoftwareManagementService : ISoftwareManagementService + { + public Task> RefreshAsync( + CancellationToken cancellationToken) => + Task.FromResult>([]); + + public void SaveLocation(string softwareId, string path) { } + + public void ClearLocation(string softwareId) { } + + public Task InstallRuntimeAsync( + string softwareId, + IProgress progress, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } + + private sealed class EmptyJobMonitorService : IJobMonitorService + { + public IReadOnlyList ReadSnapshots(int limit = 50) => []; + } + + private sealed class EmptyAnsysAcceptanceService : IAnsysAcceptanceService + { + public bool IsAvailable => false; + public bool IsGateEnabled => false; + + public Task RunAsync( + string workRoot, + IProgress progress, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public void EnableGate(string reportPath) => throw new NotSupportedException(); + } + + private sealed class EmptyDataRootManagementService : IDataRootManagementService + { + public NodeDataRootSelection ReadSelection() => + new(@"C:\node-data", IsEnvironmentManaged: false, "default"); + + public bool HasPendingJobs => false; + + public string Normalize(string path) => path; + } +} diff --git a/windows-node/Zcbot.WindowsNode.Tests/SoftwarePageViewModelTests.cs b/windows-node/Zcbot.WindowsNode.Tests/SoftwarePageViewModelTests.cs new file mode 100644 index 0000000..4340858 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/SoftwarePageViewModelTests.cs @@ -0,0 +1,145 @@ +namespace Zcbot.WindowsNode.Tests; + +public sealed class SoftwarePageViewModelTests +{ + [Fact] + public async Task Refresh_PopulatesSoftwareCardsAndExecutionState() + { + var service = new FakeSoftwareManagementService( + Snapshot("origin", "Origin", available: true), + Snapshot("ansys", "ANSYS", available: false, active: true)); + using var viewModel = new SoftwarePageViewModel(service); + + await viewModel.RefreshAsync(); + + Assert.Equal(2, viewModel.Cards.Count); + Assert.True(viewModel.Cards[0].InstallRuntimeCommand.CanExecute(null)); + Assert.False(viewModel.Cards[1].InstallRuntimeCommand.CanExecute(null)); + Assert.Equal("检测完成", viewModel.Message); + } + + [Fact] + public async Task ChooseAndClearLocation_UseSoftwareServiceThenRefresh() + { + var service = new FakeSoftwareManagementService( + Snapshot("origin", "Origin", available: false)); + using var viewModel = new SoftwarePageViewModel(service); + viewModel.ChooseLocationRequested += (_, _, _) => @"C:\Origin\Origin.exe"; + await viewModel.RefreshAsync(); + var card = Assert.Single(viewModel.Cards); + + card.ChooseLocationCommand.Execute(null); + await WaitUntilAsync(() => service.SavedPath is not null); + await WaitUntilAsync(() => !viewModel.IsBusy); + + Assert.Equal(("origin", @"C:\Origin\Origin.exe"), service.SavedPath); + Assert.Equal("应用位置已保存", viewModel.Message); + + card.ClearLocationCommand.Execute(null); + await WaitUntilAsync(() => service.ClearedId is not null); + await WaitUntilAsync(() => !viewModel.IsBusy); + Assert.Equal("origin", service.ClearedId); + } + + [Fact] + public async Task InstallRuntime_RequiresConfirmationAndReportsResult() + { + var service = new FakeSoftwareManagementService( + Snapshot("blender", "Blender", available: true)); + using var viewModel = new SoftwarePageViewModel(service); + viewModel.ConfirmInstallRequested += _ => true; + await viewModel.RefreshAsync(); + + viewModel.Cards[0].InstallRuntimeCommand.Execute(null); + await WaitUntilAsync(() => service.InstallCount == 1); + await WaitUntilAsync(() => !viewModel.IsBusy); + + Assert.Contains("安装完成", viewModel.Message); + Assert.Equal("blender", service.InstalledId); + } + + [Fact] + public async Task CancelInstall_CancelsTheInFlightRuntimeOperation() + { + var service = new FakeSoftwareManagementService( + Snapshot("origin", "Origin", available: true)) + { + BlockInstall = true, + }; + using var viewModel = new SoftwarePageViewModel(service); + viewModel.ConfirmInstallRequested += _ => true; + await viewModel.RefreshAsync(); + + viewModel.Cards[0].InstallRuntimeCommand.Execute(null); + await service.InstallStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.True(viewModel.CancelInstallCommand.CanExecute(null)); + viewModel.CancelInstallCommand.Execute(null); + await WaitUntilAsync(() => !viewModel.IsBusy); + + Assert.Equal("安装已取消,原运行环境保持不变。", viewModel.Message); + } + + private static SoftwareManagementSnapshot Snapshot( + string id, + string displayName, + bool available, + bool active = false) => + new( + id, + displayName, + SoftwarePathKind.Executable, + "*.exe", + available, + available ? $@"C:\{displayName}\app.exe" : "未检测到应用位置", + available ? "运行环境已安装" : "运行环境尚未安装", + RuntimeInstalled: available, + HasActiveJobs: active); + + private static async Task WaitUntilAsync(Func condition) + { + var deadline = DateTime.UtcNow.AddSeconds(2); + while (!condition() && DateTime.UtcNow < deadline) + { + await Task.Delay(10); + } + Assert.True(condition()); + } + + private sealed class FakeSoftwareManagementService( + params SoftwareManagementSnapshot[] snapshots) : ISoftwareManagementService + { + internal (string Id, string Path)? SavedPath { get; private set; } + internal string? ClearedId { get; private set; } + internal string? InstalledId { get; private set; } + internal int InstallCount { get; private set; } + internal bool BlockInstall { get; init; } + internal TaskCompletionSource InstallStarted { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public Task> RefreshAsync( + CancellationToken cancellationToken) => + Task.FromResult>(snapshots); + + public void SaveLocation(string softwareId, string path) => + SavedPath = (softwareId, path); + + public void ClearLocation(string softwareId) => ClearedId = softwareId; + + public async Task InstallRuntimeAsync( + string softwareId, + IProgress progress, + CancellationToken cancellationToken) + { + InstallCount++; + InstalledId = softwareId; + InstallStarted.TrySetResult(); + progress.Report("正在安装…"); + if (BlockInstall) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + return new RuntimeInstallResult(@"C:\runtime\python.exe", "安装完成"); + } + } +} diff --git a/windows-node/Zcbot.WindowsNode.Tests/TransferRetryPolicyTests.cs b/windows-node/Zcbot.WindowsNode.Tests/TransferRetryPolicyTests.cs new file mode 100644 index 0000000..b4700ea --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/TransferRetryPolicyTests.cs @@ -0,0 +1,111 @@ +using System.Net; + +namespace Zcbot.WindowsNode.Tests; + +public sealed class TransferRetryPolicyTests +{ + [Theory] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + public void AuthenticationFailuresAreClassifiedSeparately(HttpStatusCode statusCode) + { + Assert.Equal( + TransferFailureKind.Authentication, + TransferRetryPolicy.Classify(statusCode)); + Assert.Null(TransferRetryPolicy.RetryDelay(HttpMethod.Get, 0, statusCode)); + } + + [Theory] + [InlineData(HttpStatusCode.RequestTimeout)] + [InlineData(HttpStatusCode.TooManyRequests)] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public void SafeDownloadsRetryTransientResponses(HttpStatusCode statusCode) + { + Assert.Equal(TransferFailureKind.Transient, TransferRetryPolicy.Classify(statusCode)); + Assert.NotNull(TransferRetryPolicy.RetryDelay(HttpMethod.Get, 0, statusCode)); + Assert.Null(TransferRetryPolicy.RetryDelay(HttpMethod.Post, 0, statusCode)); + } + + [Fact] + public void CancellationIsNeverClassifiedAsANetworkFailure() + { + Assert.Equal( + TransferFailureKind.Cancelled, + TransferRetryPolicy.Classify(new OperationCanceledException())); + } + + [Fact] + public async Task HttpClientRetriesGetAndInjectsAuthenticationWithoutSecretsInTheUrl() + { + var handler = new SequenceHandler( + HttpStatusCode.ServiceUnavailable, + HttpStatusCode.OK); + using var client = new NodeHttpClient(CreateConfig(), handler); + + using var response = await client.SendWithRetryAsync( + () => client.CreateRequest(HttpMethod.Get, "/v1/software-jobs/example/input"), + HttpCompletionOption.ResponseHeadersRead, + CancellationToken.None); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, handler.RequestCount); + Assert.Equal("Bearer node-secret", handler.Authorization); + Assert.Equal("node-secret", CreateConfig().NodeToken); + Assert.DoesNotContain("node-secret", handler.RequestUri!.ToString()); + Assert.StartsWith("zcbot-windows-node/", handler.UserAgent); + } + + [Fact] + public async Task HttpClientDoesNotRetryAuthenticationFailure() + { + var handler = new SequenceHandler(HttpStatusCode.Unauthorized, HttpStatusCode.OK); + using var client = new NodeHttpClient(CreateConfig(), handler); + + using var response = await client.SendWithRetryAsync( + () => client.CreateRequest(HttpMethod.Get, "/v1/software-jobs/example/input"), + HttpCompletionOption.ResponseHeadersRead, + CancellationToken.None); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(1, handler.RequestCount); + } + + private static NodeConfig CreateConfig() => new( + new Uri("https://node.example/"), + Guid.Parse("11111111-1111-1111-1111-111111111111"), + Guid.Parse("22222222-2222-2222-2222-222222222222"), + "test-node", + "node-secret", + 30, + ["origin.plot@v2"]); + + private sealed class SequenceHandler(params HttpStatusCode[] statuses) : HttpMessageHandler + { + private int index; + + internal int RequestCount { get; private set; } + + internal string? Authorization { get; private set; } + + internal Uri? RequestUri { get; private set; } + + internal string UserAgent { get; private set; } = ""; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestCount++; + Authorization = request.Headers.Authorization?.ToString(); + RequestUri = request.RequestUri; + UserAgent = request.Headers.UserAgent.ToString(); + var status = statuses[Math.Min(index++, statuses.Length - 1)]; + return Task.FromResult(new HttpResponseMessage(status) + { + Content = new ByteArrayContent([]), + RequestMessage = request, + }); + } + } +} diff --git a/windows-node/Zcbot.WindowsNode.Tests/WorkspaceCompatibilityTests.cs b/windows-node/Zcbot.WindowsNode.Tests/WorkspaceCompatibilityTests.cs new file mode 100644 index 0000000..e7b9c6a --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/WorkspaceCompatibilityTests.cs @@ -0,0 +1,83 @@ +namespace Zcbot.WindowsNode.Tests; + +public sealed class WorkspaceCompatibilityTests : IDisposable +{ + private readonly string root = Path.Combine( + Path.GetTempPath(), "zcbot-workspace-tests", Guid.NewGuid().ToString("N")); + + [Fact] + public void NewWorkspacePromotesOutputAndPersistsHeadMetadata() + { + var workspaceId = Guid.NewGuid(); + var job = CreateJob(workspaceId, sourceJobId: null, mode: "new"); + var paths = Paths(); + var store = new WorkspaceStore(paths); + store.Prepare(job, "origin.plot@v2"); + var output = Path.Combine(paths.JobsDirectory, job.JobId.ToString("D"), "output"); + Directory.CreateDirectory(output); + File.WriteAllText(Path.Combine(output, "project.opju"), "workspace-state"); + + var size = store.Promote(job, "origin.plot@v2", "project.opju"); + + Assert.True(size > 0); + Assert.True(File.Exists(Path.Combine( + paths.WorkspacesDirectory, + workspaceId.ToString("D"), + "current", + "project.opju"))); + Assert.True(File.Exists(Path.Combine( + paths.JobsDirectory, job.JobId.ToString("D"), "workspace-result.json"))); + } + + [Fact] + public void FailedContinuationRestoresThePreviousCurrentDirectory() + { + var workspaceId = Guid.NewGuid(); + var first = CreateJob(workspaceId, sourceJobId: null, mode: "new"); + var paths = Paths(); + var store = new WorkspaceStore(paths); + store.Prepare(first, "origin.plot@v2"); + var firstOutput = Path.Combine( + paths.JobsDirectory, first.JobId.ToString("D"), "output"); + Directory.CreateDirectory(firstOutput); + File.WriteAllText(Path.Combine(firstOutput, "project.opju"), "stable-state"); + store.Promote(first, "origin.plot@v2", "project.opju"); + var continued = CreateJob(workspaceId, first.JobId, "continue"); + + store.Prepare(continued, "origin.plot@v2"); + store.Restore(continued); + + var current = store.CurrentDirectory(continued); + Assert.Equal("stable-state", File.ReadAllText(Path.Combine(current, "project.opju"))); + Assert.False(Directory.Exists(Path.Combine( + paths.WorkspacesDirectory, workspaceId.ToString("D"), "rollback"))); + } + + public void Dispose() + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + + private NodePaths Paths() => new( + root, + Path.Combine(root, "node.json"), + Path.Combine(root, "jobs"), + Path.Combine(root, "workspaces")); + + private static RecoverableJob CreateJob( + Guid workspaceId, + Guid? sourceJobId, + string mode) => new( + Guid.NewGuid(), + Guid.NewGuid(), + new string('a', 64), + "origin.plot@v2", + new WorkspaceBinding(workspaceId, sourceJobId, mode), + null, + null, + false, + false); +} diff --git a/windows-node/Zcbot.WindowsNode.Tests/Zcbot.WindowsNode.Tests.csproj b/windows-node/Zcbot.WindowsNode.Tests/Zcbot.WindowsNode.Tests.csproj new file mode 100644 index 0000000..3855977 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/Zcbot.WindowsNode.Tests.csproj @@ -0,0 +1,23 @@ + + + net10.0-windows + true + enable + enable + false + true + true + + + + + + + + + + + + + + diff --git a/windows-node/Zcbot.WindowsNode.Tests/packages.lock.json b/windows-node/Zcbot.WindowsNode.Tests/packages.lock.json new file mode 100644 index 0000000..0ed0406 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.Tests/packages.lock.json @@ -0,0 +1,137 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows7.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.14.1, )", + "resolved": "17.14.1", + "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", + "dependencies": { + "Microsoft.CodeCoverage": "17.14.1", + "Microsoft.TestPlatform.TestHost": "17.14.1" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.4, )", + "resolved": "3.1.4", + "contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg==" + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "3.0.10", + "contentHash": "yZIhtw8sYuvsONzQbZxWpR60tMWYHXoo0DL6nyOqSFiU5POjBTSEyWFpTQtJEZuy+oqiYTXKXY/Mjx7KnqIQFw==" + }, + "Json.More.Net": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "fRctF2J2SILYG6wqP21drmeEODmCVkVQ/b3MndDu2fT1swfySyUgq7ePCk+aENGlDcIm05fyfjh9vcuqDEfv3w==" + }, + "JsonPointer.Net": { + "type": "Transitive", + "resolved": "7.0.2", + "contentHash": "oClYHv2ooeRrtPZyC9sb/Za/ie5pGhjTbNHlAyFg4XOCyU+606FdoZKS7UdaWvtsPlrF+U2w0Ja3TRvn5+spyA==", + "dependencies": { + "Humanizer.Core": "3.0.10", + "Json.More.Net": "3.0.1" + } + }, + "JsonSchema.Net": { + "type": "Transitive", + "resolved": "9.4.0", + "contentHash": "muE4nPuzbD9x5XA1mkXJNqX4mhz47oF3EY8qFLY1pyUY7lhXFKq65t/dSKIHeNSLBV17zwZuq24MD1em90rMNg==", + "dependencies": { + "JsonPointer.Net": "7.0.2" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.14.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "zcbot.windowsnode": { + "type": "Project", + "dependencies": { + "JsonSchema.Net": "[9.4.0, )" + } + } + } + } +} \ No newline at end of file diff --git a/windows-node/Zcbot.WindowsNode.slnx b/windows-node/Zcbot.WindowsNode.slnx index c6bd4e2..ae9e966 100644 --- a/windows-node/Zcbot.WindowsNode.slnx +++ b/windows-node/Zcbot.WindowsNode.slnx @@ -1,3 +1,4 @@ + diff --git a/windows-node/Zcbot.WindowsNode/Adapters/AdapterCatalog.cs b/windows-node/Zcbot.WindowsNode/Adapters/AdapterCatalog.cs new file mode 100644 index 0000000..97154e6 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Adapters/AdapterCatalog.cs @@ -0,0 +1,59 @@ +namespace Zcbot.WindowsNode; + +internal sealed class AdapterCatalog +{ + private static readonly Lazy> InstalledDescriptors = + new(DiscoverDescriptors, LazyThreadSafetyMode.ExecutionAndPublication); + private static readonly JobExecutionGate SharedExecutionGate = new(); + private static readonly ProcessSupervisor SharedProcessSupervisor = new(); + private readonly IReadOnlyDictionary adapters; + + internal AdapterCatalog(IEnumerable values) + { + adapters = values.ToDictionary(item => item.Capability, StringComparer.Ordinal); + } + + internal static string AdapterRoot => + Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "adapters")); + + internal static AdapterCatalog CreateDefault( + JobRepository inbox, + NodePaths? paths = null) + { + var resolvedPaths = paths ?? NodePaths.ForCurrentMachine(); + return new(InstalledDescriptors.Value.Select(item => new ProcessNodeAdapter( + item, inbox, resolvedPaths, SharedExecutionGate, SharedProcessSupervisor))); + } + + internal static IReadOnlyList InstalledContracts => + InstalledDescriptors.Value.Select(item => item.Contract).ToArray(); + + internal static IReadOnlyList InstalledCapabilities => + InstalledContracts.Select(item => item.Capability).ToArray(); + + internal static bool HasActiveExecution => SharedExecutionGate.IsActive; + + internal IReadOnlyCollection All => adapters.Values.ToArray(); + + internal INodeAdapter? Find(string capability) => + adapters.TryGetValue(capability, out var adapter) ? adapter : null; + + internal void InvalidateRuntime(string runtimeId) + { + foreach (var adapter in adapters.Values.Where( + item => runtimeId.Equals(item.RuntimeId, StringComparison.Ordinal))) + { + adapter.InvalidateRuntime(); + } + } + + private static IReadOnlyList DiscoverDescriptors() + { + if (!Directory.Exists(AdapterRoot)) return []; + return Directory.EnumerateDirectories(AdapterRoot) + .Where(path => File.Exists(Path.Combine(path, "adapter.json"))) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .Select(AdapterContractLoader.Load) + .ToArray(); + } +} diff --git a/windows-node/Zcbot.WindowsNode/Adapters/AdapterContractLoader.cs b/windows-node/Zcbot.WindowsNode/Adapters/AdapterContractLoader.cs new file mode 100644 index 0000000..8fa523f --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Adapters/AdapterContractLoader.cs @@ -0,0 +1,185 @@ +using Json.Schema; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Zcbot.WindowsNode; + +internal static class AdapterContractLoader +{ + internal static AdapterDescriptor Load(string directory) + { + var root = Path.GetFullPath(directory); + var manifestPath = ResolveFile(root, "adapter.json"); + using var manifestDocument = JsonDocument.Parse(File.ReadAllBytes(manifestPath)); + var manifestRoot = manifestDocument.RootElement; + RequireOnlyProperties( + manifestRoot, "capability", "adapter_version", "runtime", "runtime_id", + "entrypoint", "contract", "worker_timeout_minutes", "running_detail"); + var manifest = new AdapterManifest( + RequiredString(manifestRoot, "capability"), + RequiredVersion(manifestRoot, "adapter_version"), + RequiredString(manifestRoot, "runtime"), + OptionalString(manifestRoot, "runtime_id"), + RequiredString(manifestRoot, "entrypoint"), + RequiredString(manifestRoot, "contract"), + OptionalInteger(manifestRoot, "worker_timeout_minutes", 30, 1, 1440), + RequiredString(manifestRoot, "running_detail")); + ValidateManifest(manifest); + + var contractPath = ResolveFile(root, manifest.Contract); + var contractBytes = File.ReadAllBytes(contractPath); + var contractSha256 = Convert.ToHexString(SHA256.HashData(contractBytes)).ToLowerInvariant(); + using var contractDocument = JsonDocument.Parse(contractBytes); + var contractRoot = contractDocument.RootElement; + var capability = RequiredString(contractRoot, "capability"); + if (!capability.Equals(manifest.Capability, StringComparison.Ordinal)) + { + throw new InvalidDataException("Adapter manifest capability does not match its contract."); + } + var currentVersion = ParseVersion(manifest.AdapterVersion); + var features = contractRoot.GetProperty("features").EnumerateObject() + .Where(item => ParseVersion(item.Value.GetString() ?? "0.0.0") <= currentVersion) + .Select(item => item.Name) + .ToArray(); + var (workspaceStateFilename, previewOutputIds) = ReadWorkspace(contractRoot); + var schema = JsonSchema.Build(contractRoot.GetProperty("request_schema").Clone()); + var entrypointPath = ResolveFile(root, manifest.Entrypoint); + ValidateEntrypoint(manifest, entrypointPath); + return new AdapterDescriptor( + root, + entrypointPath, + contractPath, + contractSha256, + manifest, + new NodeAdapterContract( + capability, + RequiredString(contractRoot, "display_name"), + features, + schema, + workspaceStateFilename, + previewOutputIds)); + } + + private static void ValidateManifest(AdapterManifest manifest) + { + if (!Regex.IsMatch(manifest.Capability, "^[a-z][a-z0-9_.-]+@v[1-9][0-9]*$")) + { + throw new InvalidDataException("Adapter capability is invalid."); + } + if (manifest.Runtime is not ("python" or "executable")) + { + throw new InvalidDataException("Adapter runtime must be python or executable."); + } + if (manifest.Runtime == "python" + && (manifest.RuntimeId is null + || !Regex.IsMatch(manifest.RuntimeId, "^[a-z][a-z0-9_-]{0,31}$"))) + { + throw new InvalidDataException("Python adapter runtime_id is invalid."); + } + if (manifest.Runtime == "executable" && manifest.RuntimeId is not null) + { + throw new InvalidDataException("Executable adapter must not declare runtime_id."); + } + } + + private static (string? StateFilename, IReadOnlyList PreviewOutputIds) ReadWorkspace( + JsonElement contractRoot) + { + if (!contractRoot.TryGetProperty("workspace", out var workspace) + || workspace.ValueKind != JsonValueKind.Object) + { + return (null, []); + } + var stateOutput = RequiredString(workspace, "state_output"); + var outputs = contractRoot.GetProperty("outputs"); + if (!outputs.TryGetProperty(stateOutput, out var stateSpec)) + { + throw new InvalidDataException("Workspace state output is missing from contract."); + } + var previewOutputIds = workspace.GetProperty("preview_outputs") + .EnumerateArray() + .Select(item => item.GetString() + ?? throw new InvalidDataException("Workspace preview output is invalid.")) + .ToArray(); + return (RequiredString(stateSpec, "filename"), previewOutputIds); + } + + private static void ValidateEntrypoint(AdapterManifest manifest, string entrypointPath) + { + if ((manifest.Runtime == "python" + && !Path.GetExtension(entrypointPath).Equals(".py", StringComparison.OrdinalIgnoreCase)) + || (manifest.Runtime == "executable" + && !Path.GetExtension(entrypointPath).Equals(".exe", StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidDataException("Adapter entrypoint extension does not match its runtime."); + } + } + + private static string ResolveFile(string root, string relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath) || Path.IsPathFullyQualified(relativePath)) + { + throw new InvalidDataException("Adapter file path must be relative."); + } + var resolved = Path.GetFullPath(Path.Combine(root, relativePath)); + if (!resolved.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || !File.Exists(resolved)) + { + throw new InvalidDataException("Adapter file is missing or outside its directory."); + } + return resolved; + } + + private static void RequireOnlyProperties(JsonElement value, params string[] names) + { + if (value.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException("Adapter manifest must be an object."); + } + var allowed = names.ToHashSet(StringComparer.Ordinal); + if (value.EnumerateObject().Any(item => !allowed.Contains(item.Name))) + { + throw new InvalidDataException("Adapter manifest contains unknown properties."); + } + } + + private static string RequiredString(JsonElement value, string name) => + value.TryGetProperty(name, out var property) + && property.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(property.GetString()) + ? property.GetString()! + : throw new InvalidDataException($"Adapter property {name} is missing."); + + private static string RequiredVersion(JsonElement value, string name) + { + var version = RequiredString(value, name); + if (!Regex.IsMatch(version, "^[0-9]+\\.[0-9]+\\.[0-9]+$") + || !Version.TryParse(version, out _)) + { + throw new InvalidDataException($"Adapter property {name} is not a semantic version."); + } + 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 + : property.ValueKind == JsonValueKind.String + ? property.GetString() + : throw new InvalidDataException($"Adapter property {name} must be a string."); + + private static Version ParseVersion(string value) => + Version.TryParse(value, out var version) ? version : new Version(0, 0, 0); +} diff --git a/windows-node/Zcbot.WindowsNode/AdapterProcessRunner.cs b/windows-node/Zcbot.WindowsNode/Adapters/AdapterExecutionService.cs similarity index 88% rename from windows-node/Zcbot.WindowsNode/AdapterProcessRunner.cs rename to windows-node/Zcbot.WindowsNode/Adapters/AdapterExecutionService.cs index f97ab73..4e3890a 100644 --- a/windows-node/Zcbot.WindowsNode/AdapterProcessRunner.cs +++ b/windows-node/Zcbot.WindowsNode/Adapters/AdapterExecutionService.cs @@ -6,11 +6,16 @@ using System.Text.Json; namespace Zcbot.WindowsNode; -internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInboxStore inbox) +internal sealed class AdapterExecutionService( + AdapterDescriptor descriptor, + JobRepository inbox, + NodePaths paths, + JobExecutionGate executionGate, + ProcessSupervisor processSupervisor) { private readonly ConcurrentDictionary active = new(); private readonly ConcurrentDictionary cancellations = new(); - private readonly WorkspaceStore workspaceStore = new(NodePaths.ForCurrentMachine()); + private readonly WorkspaceStore workspaceStore = new(paths); internal bool HasActiveJobs => !active.IsEmpty; internal string RuntimePath => ResolveCommand(descriptor.EntrypointPath).Filename; @@ -18,7 +23,13 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo internal Task RunAsync(RecoverableJob job) => active.GetOrAdd(job.JobId, _ => RunOnceAsync(job, CancellationFor(job.JobId).Token)); - internal void Cancel(Guid jobId) => CancellationFor(jobId).Cancel(); + internal void Cancel(Guid jobId) + { + if (cancellations.TryGetValue(jobId, out var cancellation)) + { + cancellation.Cancel(); + } + } internal AdapterRuntimeStatus Probe() { @@ -35,7 +46,7 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo } catch (OperationCanceledException) { - process.Kill(entireProcessTree: true); + processSupervisor.TerminateTreeAsync(process).GetAwaiter().GetResult(); return Unavailable("Adapter probe timed out."); } var output = stdout.GetAwaiter().GetResult(); @@ -124,15 +135,15 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo descriptor.DirectoryPath, ["--work-root", root, "--repeat", "20", "--cancel-after", "10", "--release-wait", "60"]); - var stdout = CaptureOutputAsync(process.StandardOutput, progress); - var stderr = CaptureOutputAsync(process.StandardError, null); + var stdout = processSupervisor.CaptureOutputAsync(process.StandardOutput, progress); + var stderr = processSupervisor.CaptureOutputAsync(process.StandardError); try { await process.WaitForExitAsync(cancellationToken); } catch (OperationCanceledException) { - await TerminateProcessTreeAsync(process); + await processSupervisor.TerminateTreeAsync(process); throw; } var output = await stdout; @@ -166,8 +177,8 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo var workspacePromoted = false; try { - var paths = NodePaths.ForCurrentMachine(); - var jobDirectory = Path.Combine(paths.JobsDirectory, job.JobId.ToString("D")); + using var execution = await executionGate.EnterAsync(cancellationToken); + var jobDirectory = inbox.JobDirectory(job.JobId); var terminalPath = Path.Combine(jobDirectory, "terminal.json"); if (File.Exists(terminalPath)) return; @@ -204,7 +215,7 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo } catch (OperationCanceledException) { - await TerminateProcessTreeAsync(process); + await processSupervisor.TerminateTreeAsync(process); if (cancellationToken.IsCancellationRequested) { inbox.WriteTerminal(job, "cancelled", "USER_CANCELLED", "Cancelled by user."); @@ -285,7 +296,6 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo { configured = Environment.GetEnvironmentVariable("ZCBOT_ORIGIN_PYTHON"); } - var paths = NodePaths.ForCurrentMachine(); var candidate = string.IsNullOrWhiteSpace(configured) ? Path.Combine(paths.RootDirectory, "runtimes", runtimeId, "Scripts", "python.exe") : configured; @@ -325,11 +335,10 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo startInfo.Environment["PYTHONUTF8"] = "1"; startInfo.Environment["PYTHONIOENCODING"] = "utf-8"; } - SoftwareRuntimeManager.ApplyProcessEnvironment( + SoftwareLocationService.ApplyProcessEnvironment( startInfo, descriptor.Manifest.RuntimeId); - return Process.Start(startInfo) - ?? throw new InvalidOperationException("Adapter worker did not start."); + return processSupervisor.Start(startInfo); } private void WriteMarker(string path, ProcessCommand command) @@ -364,36 +373,5 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo File.WriteAllText(Path.Combine(logs, "worker-process.json"), value, Encoding.UTF8); } - private static async Task TerminateProcessTreeAsync(Process process) - { - if (process.HasExited) return; - process.Kill(entireProcessTree: true); - using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - try - { - await process.WaitForExitAsync(timeout.Token); - } - catch (OperationCanceledException exception) - { - throw new InvalidOperationException( - "Adapter worker process tree did not terminate within 30 seconds.", exception); - } - } - - private static async Task CaptureOutputAsync( - StreamReader reader, IProgress? progress) - { - var output = new StringBuilder(); - while (await reader.ReadLineAsync() is { } line) - { - if (output.Length < 16 * 1024) - { - output.AppendLine(line); - } - progress?.Report(line); - } - return output.ToString(); - } - private sealed record ProcessCommand(string Filename, IReadOnlyList PrefixArguments); } diff --git a/windows-node/Zcbot.WindowsNode/Adapters/JobExecutionGate.cs b/windows-node/Zcbot.WindowsNode/Adapters/JobExecutionGate.cs new file mode 100644 index 0000000..7717f61 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Adapters/JobExecutionGate.cs @@ -0,0 +1,31 @@ +namespace Zcbot.WindowsNode; + +internal sealed class JobExecutionGate : IDisposable +{ + private readonly SemaphoreSlim gate = new(1, 1); + private int activeCount; + + internal bool IsActive => Volatile.Read(ref activeCount) > 0; + + internal async Task EnterAsync(CancellationToken cancellationToken) + { + await gate.WaitAsync(cancellationToken); + Interlocked.Increment(ref activeCount); + return new Lease(this); + } + + public void Dispose() => gate.Dispose(); + + private void Release() + { + Interlocked.Decrement(ref activeCount); + gate.Release(); + } + + private sealed class Lease(JobExecutionGate owner) : IDisposable + { + private JobExecutionGate? current = owner; + + public void Dispose() => Interlocked.Exchange(ref current, null)?.Release(); + } +} diff --git a/windows-node/Zcbot.WindowsNode/Adapters/ProcessSupervisor.cs b/windows-node/Zcbot.WindowsNode/Adapters/ProcessSupervisor.cs new file mode 100644 index 0000000..cf283f3 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Adapters/ProcessSupervisor.cs @@ -0,0 +1,49 @@ +using System.Diagnostics; +using System.IO; +using System.Text; + +namespace Zcbot.WindowsNode; + +internal sealed class ProcessSupervisor +{ + internal Process Start( + ProcessStartInfo startInfo, + string failureMessage = "Adapter worker did not start.") => + Process.Start(startInfo) + ?? throw new InvalidOperationException(failureMessage); + + internal async Task TerminateTreeAsync( + Process process, + CancellationToken cancellationToken = default) + { + if (process.HasExited) return; + process.Kill(entireProcessTree: true); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(30)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new InvalidOperationException( + "Adapter worker process tree did not terminate within 30 seconds.", exception); + } + } + + internal async Task CaptureOutputAsync( + StreamReader reader, + IProgress? progress = null) + { + var output = new StringBuilder(); + while (await reader.ReadLineAsync() is { } line) + { + if (output.Length < 16 * 1024) + { + output.AppendLine(line); + } + progress?.Report(line); + } + return output.ToString(); + } +} diff --git a/windows-node/Zcbot.WindowsNode/Application/AnsysAcceptanceService.cs b/windows-node/Zcbot.WindowsNode/Application/AnsysAcceptanceService.cs new file mode 100644 index 0000000..82e44ac --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Application/AnsysAcceptanceService.cs @@ -0,0 +1,76 @@ +using System.Security; +using System.Text.Json; + +namespace Zcbot.WindowsNode; + +internal interface IAnsysAcceptanceService +{ + bool IsAvailable { get; } + bool IsGateEnabled { get; } + + Task RunAsync( + string workRoot, + IProgress progress, + CancellationToken cancellationToken); + + void EnableGate(string reportPath); +} + +internal sealed class AnsysAcceptanceService : IAnsysAcceptanceService +{ + private const string Capability = "ansys.mechanical.static_structural@v2"; + private const string GateEnvironmentVariable = "ZCBOT_ANSYS_242_VALIDATED"; + private readonly INodeAdapter? adapter; + + internal AnsysAcceptanceService(NodePaths paths) + { + adapter = AdapterCatalog.CreateDefault( + new JobRepository(paths.JobsDirectory), + paths).Find(Capability); + } + + public bool IsAvailable => adapter?.SupportsLocalAcceptance == true; + + public bool IsGateEnabled => Environment.GetEnvironmentVariable( + GateEnvironmentVariable, + EnvironmentVariableTarget.Machine) == "1"; + + public Task RunAsync( + string workRoot, + IProgress progress, + CancellationToken cancellationToken) => + IsAvailable + ? adapter!.RunLocalAcceptanceAsync(workRoot, progress, cancellationToken) + : throw new InvalidOperationException( + "当前安装包没有 ANSYS 验收工具。请先安装新版完整 Node 包。"); + + public void EnableGate(string reportPath) + { + if (!ReportPassed(reportPath)) + { + throw new InvalidDataException("没有可验证的本次验收通过报告,执行门不会开启。"); + } + Environment.SetEnvironmentVariable( + GateEnvironmentVariable, + "1", + EnvironmentVariableTarget.Machine); + } + + internal static bool ReportPassed(string path) + { + try + { + using var document = JsonDocument.Parse(File.ReadAllBytes(path)); + return document.RootElement.TryGetProperty("passed", out var passed) + && passed.ValueKind == JsonValueKind.True; + } + catch (Exception exception) when ( + exception is IOException + or JsonException + or SecurityException + or UnauthorizedAccessException) + { + return false; + } + } +} diff --git a/windows-node/Zcbot.WindowsNode/Application/DataRootManagementService.cs b/windows-node/Zcbot.WindowsNode/Application/DataRootManagementService.cs new file mode 100644 index 0000000..8c465b5 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Application/DataRootManagementService.cs @@ -0,0 +1,19 @@ +namespace Zcbot.WindowsNode; + +internal interface IDataRootManagementService +{ + NodeDataRootSelection ReadSelection(); + bool HasPendingJobs { get; } + string Normalize(string path); +} + +internal sealed class DataRootManagementService(NodePaths paths) : IDataRootManagementService +{ + private readonly JobRepository jobs = new(paths.JobsDirectory); + + public NodeDataRootSelection ReadSelection() => NodeDataRootSettings.Resolve(); + + public bool HasPendingJobs => jobs.HasPendingJobs; + + public string Normalize(string path) => NodeDataRootSettings.Normalize(path); +} diff --git a/windows-node/Zcbot.WindowsNode/Application/DiagnosticService.cs b/windows-node/Zcbot.WindowsNode/Application/DiagnosticService.cs new file mode 100644 index 0000000..dfc438d --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Application/DiagnosticService.cs @@ -0,0 +1,72 @@ +using System.Text; + +namespace Zcbot.WindowsNode; + +internal interface IDiagnosticService +{ + string Build(NodeConfig config); +} + +internal sealed class DiagnosticService : IDiagnosticService +{ + private readonly NodePaths paths; + private readonly AdapterCatalog adapters; + + internal DiagnosticService(NodePaths paths) + { + this.paths = paths; + adapters = AdapterCatalog.CreateDefault(new JobRepository(paths.JobsDirectory), paths); + } + + public string Build(NodeConfig config) + { + var builder = new StringBuilder() + .AppendLine("zcbot Windows Node diagnostics") + .AppendLine($"Node: {config.NodeName}") + .AppendLine($"Node ID: {config.NodeId}") + .AppendLine($"Server: {config.ServerUrl}") + .AppendLine($"Node version: {typeof(DiagnosticService).Assembly.GetName().Version}") + .AppendLine($"OS: {Environment.OSVersion.VersionString}") + .AppendLine($"Data root: {paths.RootDirectory}") + .AppendLine($"ANSYS gate: {AnsysGateState()}"); + foreach (var adapter in adapters.All.OrderBy( + item => item.Capability, + StringComparer.Ordinal)) + { + try + { + var runtime = adapter.DetectRuntime(); + builder.AppendLine() + .AppendLine($"Capability: {adapter.Capability}") + .AppendLine($"Adapter: {adapter.AdapterVersion}") + .AppendLine($"Software: {runtime.Software} {runtime.SoftwareVersion ?? "unknown"}") + .AppendLine($"Health: {runtime.Health}") + .AppendLine($"Detail: {runtime.Detail}") + .AppendLine($"Runtime: {adapter.RuntimePath}") + .AppendLine($"Contract: {adapter.ContractPath}") + .AppendLine($"Contract SHA-256: {adapter.ContractSha256}") + .AppendLine( + $"Workspace protocol: {(adapter.WorkspaceStateFilename is null ? "disabled" : "v1")}") + .AppendLine( + $"Workspace state: {adapter.WorkspaceStateFilename ?? "none"}"); + } + catch (Exception exception) when ( + exception is IOException + or InvalidOperationException + or UnauthorizedAccessException) + { + builder.AppendLine() + .AppendLine($"Capability: {adapter.Capability}") + .AppendLine($"Adapter: {adapter.AdapterVersion}") + .AppendLine($"Diagnostic error: {exception.Message}"); + } + } + return builder.ToString(); + } + + private static string AnsysGateState() => Environment.GetEnvironmentVariable( + "ZCBOT_ANSYS_242_VALIDATED", + EnvironmentVariableTarget.Machine) == "1" + ? "enabled" + : "disabled"; +} diff --git a/windows-node/Zcbot.WindowsNode/Application/JobMonitorService.cs b/windows-node/Zcbot.WindowsNode/Application/JobMonitorService.cs new file mode 100644 index 0000000..9674da3 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Application/JobMonitorService.cs @@ -0,0 +1,189 @@ +using System.Text.Json; + +namespace Zcbot.WindowsNode; + +internal interface IJobMonitorService +{ + IReadOnlyList ReadSnapshots(int limit = 50); +} + +internal sealed class JobMonitorService(string jobsDirectory) : IJobMonitorService +{ + public IReadOnlyList ReadSnapshots(int limit = 50) + { + if (!Directory.Exists(jobsDirectory)) + { + return []; + } + var snapshots = new List(); + foreach (var requestPath in Directory.EnumerateFiles( + jobsDirectory, "request.json", SearchOption.AllDirectories)) + { + try + { + using var requestDocument = JsonDocument.Parse(File.ReadAllBytes(requestPath)); + var root = requestDocument.RootElement; + if (!JobInboxStore.TryReadGuid(root, "job_id", out var jobId)) + { + continue; + } + var jobDirectory = Directory.GetParent( + Directory.GetParent(requestPath)!.FullName)!.FullName; + var acceptedAt = ReadDate(root, "accepted_at") + ?? new DateTimeOffset(File.GetCreationTimeUtc(requestPath)); + var capability = ReadString(root, "capability", "unknown"); + var title = ReadDisplayTitle(root); + var inputFilename = root.TryGetProperty("input_transfers", out var transfers) + && transfers.ValueKind == JsonValueKind.Array + ? string.Join(", ", transfers.EnumerateArray() + .Select(item => ReadString(item, "filename", "-"))) + : "-"; + var state = JobInboxStore.ReadState(Path.Combine(jobDirectory, "state.json")); + var terminal = JobInboxStore.ReadTerminal(Path.Combine(jobDirectory, "terminal.json")); + var uploadPath = Path.Combine(jobDirectory, "upload-complete.json"); + var uploadComplete = File.Exists(uploadPath); + var cloudTerminalPath = Path.Combine(jobDirectory, "cloud-terminal.json"); + var cloudTerminal = JobInboxStore.ReadTerminal(cloudTerminalPath); + var stage = state?.Stage ?? "accepted"; + var progress = state?.Progress ?? 0; + var detail = state?.Detail ?? "任务已由本机接收"; + var updatedAt = state is not null + && state.UpdatedAt != DateTimeOffset.MinValue + && state.UpdatedAt > acceptedAt + ? state.UpdatedAt + : acceptedAt; + if (terminal is JsonElement terminalValue) + { + var terminalStatus = ReadString(terminalValue, "status", "failed"); + if (terminalStatus == "succeeded" && !uploadComplete) + { + stage = "uploading_outputs"; + progress = Math.Max(progress, 90); + detail = state?.Stage == "uploading_outputs" + ? state.Detail + : "软件执行完成,等待上传结果"; + } + else + { + stage = terminalStatus; + progress = terminalStatus == "succeeded" ? 100 : progress; + detail = TerminalDetail(terminalValue, terminalStatus, detail); + } + updatedAt = LatestWrite(updatedAt, Path.Combine(jobDirectory, "terminal.json")); + } + if (uploadComplete) + { + stage = "succeeded"; + progress = 100; + detail = File.Exists(Path.Combine(jobDirectory, "workspace-result.json")) + ? "预览已上传,工程保存在本机工作区" + : "结果已上传并由云端确认"; + updatedAt = LatestWrite(updatedAt, uploadPath); + } + else if (cloudTerminal is JsonElement cloudValue) + { + var cloudStatus = ReadString(cloudValue, "status", "failed"); + stage = "cloud_terminal"; + progress = 100; + detail = cloudStatus == "cancelled" + ? "云端任务已取消,本地生成的结果仍保留" + : "云端任务已终止,本地生成的结果仍保留"; + updatedAt = LatestWrite(updatedAt, cloudTerminalPath); + } + snapshots.Add(new JobDisplaySnapshot( + jobId, + capability, + title, + inputFilename, + stage, + Math.Clamp(progress, 0, 100), + detail, + acceptedAt, + updatedAt, + uploadComplete)); + } + catch (Exception exception) when ( + exception is JsonException or IOException or UnauthorizedAccessException) + { + } + } + return snapshots + .OrderByDescending(item => item.IsActive) + .ThenByDescending(item => item.UpdatedAt) + .Take(Math.Max(1, limit)) + .ToArray(); + } + + private static string TerminalDetail(JsonElement terminal, string status, string fallback) + { + if (terminal.TryGetProperty("error", out var error)) + { + var detail = ReadString(error, "detail", ""); + if (!string.IsNullOrWhiteSpace(detail)) + { + return detail; + } + } + return status switch + { + "succeeded" => "软件任务执行成功", + "cancelled" => "任务已取消", + "failed" => "任务执行失败", + _ => fallback, + }; + } + + private static DateTimeOffset LatestWrite(DateTimeOffset current, string path) + { + var writtenAt = new DateTimeOffset(File.GetLastWriteTimeUtc(path)); + return writtenAt > current ? writtenAt : current; + } + + private static string ReadString(JsonElement value, string name, string fallback) => + value.TryGetProperty(name, out var property) + && property.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(property.GetString()) + ? property.GetString()! + : fallback; + + private static string ReadDisplayTitle(JsonElement root) + { + const string fallback = "未命名任务"; + if (root.TryGetProperty("request_summary", out var summary) + && summary.ValueKind == JsonValueKind.Object) + { + var title = ReadString(summary, "title", fallback); + if (title != fallback) + { + return title; + } + } + + if (root.TryGetProperty("request", out var request) + && request.ValueKind == JsonValueKind.Object + && request.TryGetProperty("operation", out var operation) + && operation.ValueKind == JsonValueKind.Object) + { + foreach (var action in operation.EnumerateObject()) + { + if (action.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + var title = ReadString(action.Value, "title", fallback); + if (title != fallback) + { + return title; + } + } + } + return fallback; + } + + private static DateTimeOffset? ReadDate(JsonElement value, string name) => + value.TryGetProperty(name, out var property) + && property.ValueKind == JsonValueKind.String + && DateTimeOffset.TryParse(property.GetString(), out var parsed) + ? parsed + : null; +} diff --git a/windows-node/Zcbot.WindowsNode/Application/NodeApplicationController.cs b/windows-node/Zcbot.WindowsNode/Application/NodeApplicationController.cs new file mode 100644 index 0000000..36482e7 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Application/NodeApplicationController.cs @@ -0,0 +1,280 @@ +namespace Zcbot.WindowsNode; + +internal enum NodeShutdownMode +{ + WaitForJobs, + CancelJobs, +} + +internal interface INodeConnection +{ + Task RunAsync(CancellationToken cancellationToken); + void CancelActiveJobsForExit(); +} + +internal sealed class NodeApplicationController : IDisposable +{ + private readonly INodeConfigStore store; + private readonly NodePaths paths; + private readonly Func, INodeConnection> connectionFactory; + private readonly Func enroll; + private readonly Func> migrate; + private readonly Action saveDataRoot; + private readonly SemaphoreSlim lifecycleGate = new(1, 1); + private readonly object stopSync = new(); + private CancellationTokenSource? connectionStop; + private Task? connectionTask; + private INodeConnection? connection; + private Task? stopTask; + private bool disposed; + + internal NodeApplicationController( + INodeConfigStore store, + NodePaths paths, + Func, INodeConnection> connectionFactory, + Func enroll, + Func> migrate, + Action saveDataRoot) + { + this.store = store; + this.paths = paths; + this.connectionFactory = connectionFactory; + this.enroll = enroll; + this.migrate = migrate; + this.saveDataRoot = saveDataRoot; + } + + internal event Action? StatusChanged; + + internal NodeConfig? CurrentConfig { get; private set; } + + internal NodeStatus CurrentStatus { get; private set; } = + NodeStatus.Create(NodeState.NotRegistered, "尚未注册"); + + internal int ActiveJobCount => new JobMonitorService(paths.JobsDirectory) + .ReadSnapshots() + .Count(item => item.IsActive); + + internal NodePaths Paths => paths; + + internal void Start() + { + ObjectDisposedException.ThrowIf(disposed, this); + if (!store.Exists) + { + Publish(CurrentStatus); + return; + } + + try + { + CurrentConfig = store.Load(); + Publish(NodeStatus.Create(NodeState.Connecting, "正在连接 zcbot…")); + StartConnection(); + } + catch (NodeConfigurationException exception) + { + Publish(NodeStatus.Create(NodeState.AuthenticationRequired, exception.Message)); + } + } + + internal async Task RegisterAsync( + EnrollOptions options, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(disposed, this); + await lifecycleGate.WaitAsync(cancellationToken); + try + { + Publish(NodeStatus.Create(NodeState.Connecting, "正在注册节点…")); + try + { + await enroll(options, store, cancellationToken); + CurrentConfig = store.Load(); + Publish(NodeStatus.Create(NodeState.Connecting, "注册成功,正在连接…")); + StartConnection(); + } + catch + { + Publish(NodeStatus.Create(NodeState.NotRegistered, "注册失败,请检查配置")); + throw; + } + } + finally + { + lifecycleGate.Release(); + } + } + + internal async Task ReconnectAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(disposed, this); + await lifecycleGate.WaitAsync(cancellationToken); + try + { + if (CurrentConfig is null) + { + return false; + } + + Publish(NodeStatus.Create(NodeState.Connecting, "等待本机任务收尾后重连…")); + await StopConnectionAsync(); + cancellationToken.ThrowIfCancellationRequested(); + StartConnection(); + return true; + } + finally + { + lifecycleGate.Release(); + } + } + + internal async Task ResetIdentityAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(disposed, this); + await lifecycleGate.WaitAsync(cancellationToken); + try + { + await StopConnectionAsync(); + store.DeleteLocalIdentity(); + CurrentConfig = null; + Publish(NodeStatus.Create( + NodeState.NotRegistered, + "本机身份已清除,请使用新注册码注册")); + } + finally + { + lifecycleGate.Release(); + } + } + + internal async Task MigrateDataRootAsync( + string targetRoot, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(disposed, this); + await lifecycleGate.WaitAsync(cancellationToken); + try + { + await StopConnectionAsync(); + try + { + var result = await migrate(paths.RootDirectory, targetRoot, cancellationToken); + saveDataRoot(result.TargetDirectory); + return result; + } + catch + { + StartConnection(); + throw; + } + } + finally + { + lifecycleGate.Release(); + } + } + + internal Task StopAsync(NodeShutdownMode mode) + { + lock (stopSync) + { + ObjectDisposedException.ThrowIf(disposed, this); + return stopTask ??= StopOnceAsync(mode); + } + } + + private async Task StopOnceAsync(NodeShutdownMode mode) + { + await lifecycleGate.WaitAsync(); + try + { + var activeJobs = ActiveJobCount; + Publish(NodeStatus.Create( + NodeState.Stopped, + mode == NodeShutdownMode.CancelJobs + ? "正在取消本机任务并退出…" + : activeJobs > 0 ? "等待本机任务完成后退出…" : "正在退出…")); + if (mode == NodeShutdownMode.CancelJobs) + { + connection?.CancelActiveJobsForExit(); + } + await StopConnectionAsync(); + } + finally + { + lifecycleGate.Release(); + } + } + + private void StartConnection() + { + if (CurrentConfig is null || connectionTask is { IsCompleted: false }) + { + return; + } + + connectionStop?.Dispose(); + connectionStop = new CancellationTokenSource(); + connection = connectionFactory(CurrentConfig, Publish); + connectionTask = RunConnectionAsync(connection, connectionStop.Token); + } + + private async Task RunConnectionAsync( + INodeConnection activeConnection, + CancellationToken cancellationToken) + { + try + { + await activeConnection.RunAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + Publish(NodeStatus.Create(NodeState.Stopped, "连接已停止")); + } + catch (NodeConfigurationException exception) + { + Publish(NodeStatus.Create(NodeState.AuthenticationRequired, exception.Message)); + } + catch (Exception exception) + { + Publish(NodeStatus.Create(NodeState.Offline, $"节点异常:{exception.Message}")); + } + finally + { + if (ReferenceEquals(connection, activeConnection)) + { + connection = null; + } + } + } + + private async Task StopConnectionAsync() + { + connectionStop?.Cancel(); + if (connectionTask is not null) + { + await connectionTask; + } + connectionTask = null; + connection = null; + } + + private void Publish(NodeStatus status) + { + CurrentStatus = status; + StatusChanged?.Invoke(status); + } + + public void Dispose() + { + if (disposed) + { + return; + } + disposed = true; + connectionStop?.Cancel(); + connectionStop?.Dispose(); + lifecycleGate.Dispose(); + } +} diff --git a/windows-node/Zcbot.WindowsNode/Assets/zcbot.ico b/windows-node/Zcbot.WindowsNode/Assets/zcbot.ico new file mode 100644 index 0000000000000000000000000000000000000000..b0dc1f8eaec53cec64a56b24573578ae5f5efba2 GIT binary patch literal 8166 zcmd5hXIK+mvl~KahF+DD(5o+@g9r&tiiIv%=uJerQly1m1q4M0YFCqjt3CiL@V$H@ifp>1xorkmOy~iLFKv% z0F)=3BUpj@07^HlTb=+w(|USBI$iT^K_pIFUByUV*Txb;)O@p+ow%hZsi@ua(fftRTaBUum(%SnZ z&?pRx7`lFr*z2lWHXX0%FbLOkX+k0G`$@~VQFnu;07o*UjXNWh3Hn;cGfEDdXkSl8 zvEuY53?|zrPYpQ!HC9PeqVTp6rp9XyH8APdpp;I8pjLkysr&?za-Qm5ABEo1YaN(r zRJlfRvzT!?@;){~2Lf$us0T_Ty8Ub^ttEt*Lo1<~5eB505#8PoVF#$6UU3`+Zww$f zR&IFpc?#eXwf|_34A(`_dO%y_hI)yrWypWyTyQx7wln8WdMh1(!2j8~5c5;#YF)Ll z%o3c`HyoZcHn|1*(m!2SZ4Rw%YBr|;PVup_j2-Pm&t|NNnM^Q9P>i@Di7Ku2fW}M_ zZWoe$ljI0~&NIn^{T%TIE7@E0#sgsm;Ul1!^kZs1%<{8gNNY(c@=9LggXXIR)u(W+ zcThW_sK(j~D73M*mZX-{OsuS1>i{t{5faS%?$L23?u2WO#j>^K=r%ESuUJjOzom$s zC_}i6`j%t|d0aLn_1gB_S88$?qjIZu1=4*`8s2(6IUDuujZL-~pdqFZ!Dt7mokGHO z%odm3tm;{O_t>t3q1X zWXZCs`Qk0p_Wk_&NY)=;$KY!8{HZ2pHJmPxHU>SjxPn9u&_w?X74r;eI|<$7uwFqE z3*xP1Sx@=-csS;fv=A&Y#8<{h>}wwc0_^AMeDu7UaPn^!5((0gXA9~1`0>s^TFB

vtQ9<$LhJc4@=+-hNQiPc{7+P6{&%bn@+=jr7H=T z|Dc?$rId5-&nmsxt*qBEtk_mNEJpal#BIn!+l1_noFnE?&_*1OAhFO+hYLMck8 z+ljzm_EsJJ;?ET;0`4UDM$g4EI@bS$)Y6Z^}ucJ6D z{TrC>-Du>O5#)g(p)c(c6&`7P>*O}TmN_U>4+MUQT8Z+cGAj>s|o_)_vsQ+MRw#0prW0n}Nn zn2;Ps{*zcCaXO8adunTG5?UN8OcA9_?t3IG^kTB|o^mrX=1;>gVzX2=BBrLK-%6_YzzK0gp68;j)a3X-CH<>wtJkNM4r=%9 zi9z;0b*rLG>xMfw3BlR3HnK~iFPH;ejy8|Ebffv-9hB#(CyEokvClh|Ihv;2MDb^Y z$0mkvhYP!Zm+KWQYq%4t-4Md+fxY717KHtn-f$N7@rK| zD;!R4?#P_DnSZhW=>q?tjAdvEg!9A~>ga=eEOOw#n@rKsG(S1U=OL9P`p0%Z_%1x3 z_0h$xnGF`LH8WE=U>a?fL0_KQ_0ia%DAX95ZC)wDRc7B*`h*NIkPv0sTlD^sfl~-G zw07FTTUPtMa2mVM!KVxL=))Ur*7N&S+RhgwWwJyAr!3_b5e11y(zf;h=|$p!PMdWF z6RoW^KpKn^V}a&D^TJksaWg{G8E8K^1D7aSVuRzQ4+z2qeIZrw#hA#d7^g~}kC5L3 zjwnjk+l-UjP_#JZ3fYh)PGbzT3GdW2TPnTt5M>2TPKmvNQEs8^H=N&1fl%nioJ*QT zl`ZK*b)?;rDzyUH=xMk1$z7BA6t$+mCJ1k_2r6npSK`C++Nx7|1hMp{wq^9JQ)Oy6 zwY%f)Z$Am$)G;oWlSrLKeBnt~bG;JB>)-rnN7~XwZiit-GJf;+b6-w@G+)3>!0Xu& zx@Igq7R8VJCrcmbt?fkg#lES2(4%`$ zB4syw)nm%vwX!a)_vFI^$@;CKywKLJioyh|tI#mYQ zqli$`_S8bpb(c#wZGsVOQENueR_&QJyM8dsd{*x)Vtne!or87ivg$l)XV{&` zg^ja9TZyu9-2|Mq$ZFavJDX^&*yT?6y(8(kww%UBgXZ@LE1T$$oLSmJ1lgod*9FaB z8Zlui=;`s(G+U3&atzzvksf_3u)d!(;^}#)fZay(;rjao*R&?&S95PfqA>Jfdk&(H z`FSrJR`e5Yo}ZDeZEavffPq%h3LAg$(RJhFk7F-MtEM@V0g&ffG6 zw}+}z=UddZkw&=hbi-0Q`J&Hf@p1*+;~M6(2Hv5VSv$7aMa6VE+FXD(8&1Pul}Q2Z z?KX{KvPoy>l=je{@FppCc2-bFB|EKY#;53ZfOG37^U!5&%W;6iVEvfTMoQ*hUek=@ z3wMXQ8RU?JHc6Q7JI7PTv14EY;p0enMZWi&s zIvX)nYxgW^=myK+sLaZGP6hGYE72>Qy5qTz18kDAyLzXi_r81_$gXBmf5qP={PQsU zMgU969WH-?PKJ3Z-4C6tjTbj<-JXrxMtfph`!qB5AcOdnEP)HYhZ%qrE4yN0s}?+i zL0xyAi=*0J3}LfbJj-upndt7*GKcKYk#duV^F%>ZVyQ0jyp0^< z5mLG$KF`CV9)&c$tkoX+n9fg+!l$c`gO_-n3fI?QKhaY^nnnyn5aR194}&Jw`}+wN zH`23(V!$s|esjy}P?pGm?!XJy%EJ0FO%KIN5gEEN<;O-$hPcT2k@2nvzUbtpuf2ga=W-ID1ot{HOkNCGMz#s$U;jCRf7 zT!y}^usb`y_iq?)V@#T5+bGKVGc?zxOYS*Gb>vrUOFO1Oul-zcaihHZ{ivfny*Ayb zDwkvFa_0n6YI;4`5qat+-5uxKg%Z_w(! zP8R<2Z1e17NCVZ>pyPzhOQSUQK>QRgPmZI~pLgE-;r=;z6Y8xtv=7(U8FW09dq@KP zs!p-(O4!B+e=eJj<76WPZUwG9>J*z$w{BdTEp5 z+D6E;2xvcFr^W`TT%-5=ikeWj2gip=T9}K=dy(!a0+RH}l;>a0z0&GqJz~e|%{M+g zHgi~wT66$xxA?;jD>AS)npcP<$oU?eC^+o<9fX37rdA^fUAO-zrlp)W7Y#}LU9}&0 z(l5~?N0}zR!wbCW4ch)Co?gzY83b_MxhwCp(CK<+SdXcE;Ors|mW`()>sEs=9$#u8 zZSnO~Yfn*bjtduJks1?qv!tz$^&uHD?2vPB>rmYSc>J|?G1#5^#a~4iP)$YX zi@p$lNQzV!eWAJ?z5}Ie@pokN#m$}T|4F5#2q>-1Y-`t^F^v0j_W@xji>Ob}dsvrX z7y)bP33f*q(g-mTyWuKtBg&r+q&}ZR&I^J{lBG%L3ZEA_%%9sLM?O1@{^)VYvkrW5 z0h{c}Wa`|jjF;kV;A8{*UHy-1q`VTM?p(p`k(;<`3;#pV9r;QP@g#>5X`YKJS+e%~2?-F^xcj>_yDKJ6~{#R_tF zRiMF)&+1GtQnqlBeI50-iw4X^vU8PJsRen24bmR%R6qA{s!C6O`Zm$TcV!eEqZ_=F zmLV>lUCJBn8T=3|R)}McTY0Q8m6EY-$;k?7(vQfim!ku%AoIN2i~o-RsvI);mi zHoAm}Ww#e@c~&R}ddZa+KV-)iG%8-UF}LK8?(h9UygM2$ldPi-O?DX_SmX3u{~!piO~lyunbl(?&)bHV zJ_@qX>eA?HpMo*!eoNkvP5;b!^{*sd(x7j0&mSyxY<@y$RJiCxzLC7MD$SMVEb)1m zGQ+h?bb9lJCPNJFe&1BG>PJGzx>05hoE=+YYF#yki!G|Ga06t){Qpvl&myqE%(72y*NrQqrj865xTnZKn)($J#osWl#ol8ud z13asYH~MSm*KNk;n~R-j^x_MhxQ>XSg+;FWL|!w`))R;R2QFSG#Av?PPpXved|W8) ztDWyJe!p>uzC@STVDYi9Zcji}m@nm)_j-&>_3_gdGzTiA%GWa7(p8q7bzT=w)Ry#+ zts2lOWSxZk3?Mz#smzL6n%T)x;()3~rl;xi0+0Ou(cT-l1yQgfOnvHul5` z-?X*L&5K6g>b&jj?q^$838;GE>oRl1Y;?6H2$-;{n)L>r6pe77Y|xmSJcfuq&> zo-YV>{_H^v0fzKH?p{P!hlVE*F=JPh;FGK9#0LElnh&nxyW*rsjfIkfLo)@hXl{RQ zfUzyQ9}U{r{wnK**Z+I_tm?EhOXk(CPBBE?!o z>s4YNQKlPUFL+Q_g!AM`H)uF=3f$TzZ#{WE9x9B5Hmq#0%Hdc{v)$E{8J3km5*0O3CPIZl!06~3t&Ewl%2Y_6z}7+`Ab<&@ zPKYqp2abfWQ3upT*}#rIK)=O)Qv5@=!aSAluxMdBelaksu;H+{oq3oE$N)dv7<_UH=9+bXr|AEgu9Clnb+gG6uYe zbM91iD_dhiw-BISIYk__AhN*?;sZ}ME!wFY-vD$P&If4xZYdewl!OYn>NnrA*X$H@ z;Ed8z5b-^rhGBDss2dMkyc^H2+Gq;_Vsna(_(v%9n%aZUuH<+V@IL^-y(bd{5w`5R z0bAsyFuVy5h{~2Gyzri&*ef++0^;qUGm%0jaN>**;LnpgMI2K+<-9}%XjaVFqSb3E z)Yuw>vH&+yMYVNs;KZ1p;7>G1Hb7VAG!q!*VYe^#^YghtgnJZ8lg(<0e4S!HtwB87 z$&5a5kUF&a%aaOD+!6wIR<-3pQzM@flzG%47<*yy?c_#c^pph&Oz+eDkVe{6b-1?9 z?LG$zCQS3Q6rAoLFq?B z39h90$nSbU%L5n(Xr#4psk8iE$Jbj|loKgzXikMql-PkANjg5D)j8k#kYG^+Re9 zzH82)F7S0QCi`1NCWump@adeYN1HKWLY_qsdt6UYKhbYPNwj*uTQo4k^-D~q`*$PfAejPH}z_gGQg%G_>G$3Np|L+RXKPiny zX4_BCiu7qMrUFECL;!T>VA{uBrABv+z4sDeZNQ>LeL<7w7UVBgmej1s(IBmyx0?ps zAKIH$vepAw+fl2!%$ddB+WGh(2(VOGd!z=6C>T3>2ZGwaw!v-zZfUw1%>hhyVO7Ja z9OEDLlNJD`Wo*6{e^mS-Oq{ z2Aa^AkYFFcyj5Q0{y>YD4*U2CV=FQ$(Oj;)6(#{IShuzPD1W9bjxBVYg89^~qvq&Agft_FP zw*v)YdLC3Z*v7ISSejJQV$4wTD(g8gsOaA62T$G?X3ND^3@i-2@8C zDvyj!CaS{S2t?8I6326DenW{@qfF6t=>Y>Y`iH5RnAPRxX{#J17pV@4y*1&_w| new( + ConfigStore, + Paths, + (config, statusChanged) => new NodeConnectionLoop(config, Paths, statusChanged), + EnrollmentClient.EnrollAsync, + NodeDataMigrator.MigrateAsync, + NodeDataRootSettings.SaveUserRoot); + + internal async Task RunHeadlessAsync(CancellationToken cancellationToken) + { + var config = ConfigStore.Load(); + await new NodeConnectionLoop(config, Paths).RunAsync(cancellationToken); + } +} diff --git a/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs b/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs deleted file mode 100644 index 82ce6cc..0000000 --- a/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs +++ /dev/null @@ -1,1372 +0,0 @@ -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Security; -using System.Text; -using System.Text.Json; - -namespace Zcbot.WindowsNode; - -internal sealed class ConfigurationForm : Form -{ - private readonly TextBox server = CreateTextBox("http://127.0.0.1:8765"); - private readonly TextBox nodeName = CreateTextBox(Environment.MachineName.ToLowerInvariant()); - private readonly TextBox enrollmentCode = CreateTextBox(usePassword: true); - private readonly Button register = CreateButton("注册并连接", 128, primary: true); - private readonly Button reconnect = CreateButton("立即重连", 112, primary: true); - private readonly Button resetIdentity = CreateButton("清除本机身份", 132, danger: true); - 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() - { - Text = "登录 Windows 后自动启动节点", - AutoSize = true, - Margin = new Padding(0, 4, 0, 8), - }; - private readonly Label state = new() - { - AutoSize = true, - Anchor = AnchorStyles.Right, - Font = new Font("Microsoft YaHei UI", 10, FontStyle.Bold), - Margin = new Padding(12, 2, 0, 2), - Padding = new Padding(10, 5, 10, 5), - }; - private readonly Label detail = CreateBodyLabel(); - private readonly Label identity = CreateBodyLabel(); - private readonly Label capabilitySummary = CreateBodyLabel(); - private readonly Label ansysAcceptanceStatus = CreateBodyLabel(); - private readonly ProgressBar ansysAcceptanceProgress = new() - { - Style = ProgressBarStyle.Marquee, - MarqueeAnimationSpeed = 30, - Height = 8, - Dock = DockStyle.Top, - Visible = false, - Margin = new Padding(0, 8, 0, 5), - }; - private readonly Label jobSummary = CreateBodyLabel(); - private readonly Label jobDetail = CreateBodyLabel(); - private readonly DataGridView jobGrid = CreateJobGrid(); - private readonly JobInboxStore jobInbox = new(NodePaths.ForCurrentMachine().JobsDirectory); - private readonly NodeAdapterRegistry adapters; - private readonly Dictionary softwareControls = new(StringComparer.Ordinal); - private readonly System.Windows.Forms.Timer jobRefreshTimer = new() { Interval = 1000 }; - private readonly TableLayoutPanel registrationCard; - private readonly TableLayoutPanel ansysAcceptanceCard; - private bool changingStartup; - private NodeConfig? currentConfig; - private string? passedAcceptanceReport; - private CancellationTokenSource? acceptanceStop; - private CancellationTokenSource? runtimeInstallStop; - private bool dataRootMigrationInProgress; - - internal event Func? RegisterRequested; - internal event Action? ReconnectRequested; - internal event Action? ResetIdentityRequested; - internal event Func>? DataRootMigrationRequested; - - internal ConfigurationForm() - { - adapters = NodeAdapterRegistry.CreateDefault(jobInbox); - Text = "zcbot Windows Node"; - AutoScaleMode = AutoScaleMode.Dpi; - ClientSize = new Size(1080, 1050); - MinimumSize = new Size(760, 560); - StartPosition = FormStartPosition.CenterScreen; - Font = new Font("Microsoft YaHei UI", 9); - FormBorderStyle = FormBorderStyle.Sizable; - BackColor = Color.White; - - var shell = new TableLayoutPanel - { - Dock = DockStyle.Fill, - ColumnCount = 1, - RowCount = 1, - Padding = new Padding(24, 20, 24, 20), - BackColor = BackColor, - }; - shell.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); - shell.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - Controls.Add(shell); - - var page = new TableLayoutPanel - { - Dock = DockStyle.Fill, - ColumnCount = 1, - RowCount = 2, - BackColor = BackColor, - }; - page.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); - page.RowStyles.Add(new RowStyle(SizeType.AutoSize)); - page.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - shell.Controls.Add(page, 0, 0); - - var heading = new TableLayoutPanel - { - AutoSize = true, - Dock = DockStyle.Top, - ColumnCount = 2, - Margin = new Padding(2, 0, 2, 14), - }; - heading.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); - heading.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); - heading.Controls.Add(new Label - { - Text = "zcbot Windows Node", - AutoSize = true, - Anchor = AnchorStyles.Left, - Font = new Font("Microsoft YaHei UI", 16, FontStyle.Bold), - ForeColor = Color.FromArgb(15, 23, 42), - }, 0, 0); - heading.Controls.Add(state, 1, 0); - page.Controls.Add(heading); - - var tabs = new NavigationTabControl - { - Dock = DockStyle.Fill, - Margin = new Padding(0), - Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold), - Padding = new Point(14, 6), - SizeMode = TabSizeMode.FillToRight, - }; - var overviewPage = AddTab(tabs, "节点概览"); - var softwarePage = AddTab(tabs, "专业软件"); - var jobsPage = AddTab(tabs, "本机任务"); - var settingsPage = AddTab(tabs, "运行设置"); - page.Controls.Add(tabs, 0, 1); - - var statusCard = CreateSection(); - statusCard.Controls.Add(CreateSectionTitle("节点信息")); - statusCard.Controls.Add(detail); - statusCard.Controls.Add(identity); - statusCard.Controls.Add(CreateDivider()); - statusCard.Controls.Add(CreateSubsectionTitle("可用软件")); - statusCard.Controls.Add(capabilitySummary); - var resetActions = CreateActions(); - resetActions.Controls.Add(reconnect); - resetActions.Controls.Add(copyDiagnostics); - resetActions.Controls.Add(resetIdentity); - statusCard.Controls.Add(resetActions); - overviewPage.Controls.Add(statusCard); - - var softwareCard = CreateSection(); - softwareCard.Controls.Add(CreateSectionTitle("专业软件")); - softwareCard.Controls.Add(CreateHint( - "只需配置这台电脑实际拥有的软件。应用位置和运行环境彼此独立,不会影响其他软件。")); - foreach (var definition in SoftwareRuntimeManager.Definitions) - { - softwareCard.Controls.Add(CreateSoftwarePanel(definition)); - } - softwarePage.Controls.Add(softwareCard); - - ansysAcceptanceCard = CreateSection(); - ansysAcceptanceCard.Controls.Add(CreateDivider()); - ansysAcceptanceCard.Controls.Add(CreateSectionTitle("ANSYS Mechanical 真机验收")); - ansysAcceptanceCard.Controls.Add(CreateHint( - "执行门关闭时也可本地验收。程序使用自带的标准试件,自动建立 fixed_face 和 load_face;先验证完整进程树取消,再连续求解 20 次并检查结果与进程释放。")); - ansysAcceptanceCard.Controls.Add(ansysAcceptanceStatus); - ansysAcceptanceCard.Controls.Add(ansysAcceptanceProgress); - var acceptanceActions = CreateActions(); - acceptanceActions.Controls.Add(runAnsysAcceptance); - acceptanceActions.Controls.Add(enableAnsysGate); - ansysAcceptanceCard.Controls.Add(acceptanceActions); - softwarePage.Controls.Add(ansysAcceptanceCard); - - var jobsCard = CreateSection(); - jobsCard.Controls.Add(CreateSectionTitle("本机任务")); - jobsCard.Controls.Add(CreateHint( - "仅显示已经派发到本机的任务;状态来自本地持久化记录,断线或重启后仍可查看。")); - jobsCard.Controls.Add(jobSummary); - jobsCard.Controls.Add(jobGrid); - jobsCard.Controls.Add(jobDetail); - jobsPage.Controls.Add(jobsCard); - - registrationCard = CreateSection(); - registrationCard.Controls.Add(CreateDivider()); - registrationCard.Controls.Add(CreateSectionTitle("首次注册")); - registrationCard.Controls.Add(CreateHint( - "从 zcbot 管理后台生成注册码。注册码默认 10 分钟有效,成功注册一次后立即失效。")); - AddField(registrationCard, "zcbot 服务地址", server, "云端填写站点根地址;本机测试使用 http://127.0.0.1:8765"); - AddField(registrationCard, "节点名称", nodeName, "必须与注册码限定的节点名称完全一致"); - AddField(registrationCard, "一次性注册码", enrollmentCode, "格式类似 ZCN-…;仅用于首次注册,不会长期保存"); - var registerActions = CreateActions(); - registerActions.Controls.Add(register); - registrationCard.Controls.Add(registerActions); - overviewPage.Controls.Add(registrationCard); - - var runtimeCard = CreateSection(); - 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( - "关闭此窗口后,节点仍在系统托盘运行。右键托盘图标可以立即重连或退出。")); - settingsPage.Controls.Add(runtimeCard); - - var securityNote = CreateHint( - "安全说明:Node Token 由 Windows DPAPI 加密保存,不在界面显示,也不会写入日志。"); - securityNote.Margin = new Padding(4, 8, 4, 0); - settingsPage.Controls.Add(securityNote); - - register.Click += async (_, _) => await RegisterAsync(); - reconnect.Click += (_, _) => ReconnectRequested?.Invoke(); - resetIdentity.Click += (_, _) => ResetIdentity(); - copyDiagnostics.Click += (_, _) => CopyDiagnostics(); - 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) - { - eventArgs.Cancel = true; - Hide(); - } - }; - startAtLogin.Checked = StartupRegistration.IsEnabled; - dataRoot.ReadOnly = true; - RefreshDataRoot(); - jobGrid.SelectionChanged += (_, _) => ShowSelectedJob(); - jobRefreshTimer.Tick += (_, _) => RefreshJobs(); - jobRefreshTimer.Start(); - Disposed += (_, _) => - { - acceptanceStop?.Cancel(); - acceptanceStop?.Dispose(); - runtimeInstallStop?.Cancel(); - runtimeInstallStop?.Dispose(); - jobRefreshTimer.Dispose(); - }; - var ansysAdapter = adapters.Find("ansys.mechanical.static_structural@v2"); - ansysAcceptanceCard.Visible = ansysAdapter?.SupportsLocalAcceptance == true; - enableAnsysGate.Enabled = false; - UpdateAnsysAcceptanceStatus(); - RefreshSoftwareCards(); - RefreshJobs(); - ApplyStatus(NodeStatus.Create(NodeState.NotRegistered, "尚未注册"), null); - } - - internal void ApplyStatus(NodeStatus status, NodeConfig? config) - { - currentConfig = config; - state.Text = status.State switch - { - NodeState.Online => "● 在线", - NodeState.Connecting => "● 正在连接", - NodeState.NotRegistered => "● 尚未注册", - NodeState.AuthenticationRequired => "● 需要重新注册", - NodeState.Offline => "● 离线", - _ => "● 已停止", - }; - state.ForeColor = status.State switch - { - NodeState.Online => Color.FromArgb(21, 128, 61), - NodeState.Connecting => Color.FromArgb(180, 83, 9), - NodeState.NotRegistered or NodeState.AuthenticationRequired => Color.FromArgb(185, 28, 28), - _ => Color.FromArgb(71, 85, 105), - }; - state.BackColor = status.State switch - { - NodeState.Online => Color.FromArgb(240, 253, 244), - NodeState.Connecting => Color.FromArgb(255, 251, 235), - NodeState.NotRegistered or NodeState.AuthenticationRequired => Color.FromArgb(254, 242, 242), - _ => Color.FromArgb(241, 245, 249), - }; - detail.Text = $"{status.Message} {status.ChangedAt:HH:mm:ss}"; - identity.Text = config is null - ? "尚未建立节点身份" - : $"节点:{config.NodeName}\nNode ID:{config.NodeId}\n服务:{config.ServerUrl}"; - capabilitySummary.Text = config is null - ? "注册后启用" - : FormatCapabilitySummary(adapters.All); - copyDiagnostics.Enabled = config is not null; - UpdateAnsysAcceptanceStatus(); - - var registered = config is not null; - registrationCard.Visible = !registered; - reconnect.Visible = registered; - resetIdentity.Visible = registered; - register.Enabled = !registered && status.State != NodeState.Connecting; - if (registered) - { - server.Text = config!.ServerUrl.AbsoluteUri.TrimEnd('/'); - nodeName.Text = config.NodeName; - enrollmentCode.Clear(); - } - } - - private static string FormatCapabilitySummary(IEnumerable values) - { - var entries = values.Select(adapter => - { - var runtime = adapter.DetectRuntime(); - var softwareName = runtime.Software == "OriginPro" ? "Origin" : runtime.Software; - return new CapabilitySummaryEntry( - softwareName, - runtime.SoftwareVersion, - ShortCapabilityName(adapter.DisplayName, softwareName), - runtime.Health == "ready"); - }).ToArray(); - return string.Join("\n\n", entries - .GroupBy(entry => entry.SoftwareName, StringComparer.Ordinal) - .Select(group => - { - var versions = group - .Select(entry => entry.SoftwareVersion) - .Where(version => !string.IsNullOrWhiteSpace(version)) - .Distinct(StringComparer.Ordinal) - .ToArray(); - var heading = versions.Length == 0 - ? $"{group.Key} · 版本未知" - : $"{group.Key} {string.Join(" / ", versions)}"; - var capabilities = group - .GroupBy(entry => entry.CapabilityName, StringComparer.Ordinal) - .Select(capability => - { - var state = capability.All(entry => entry.Ready) - ? "可用" - : capability.Any(entry => entry.Ready) ? "部分可用" : "需配置"; - return $" {capability.Key} {state}"; - }); - return string.Join("\n", capabilities.Prepend(heading)); - })); - } - - private static string ShortCapabilityName(string displayName, string softwareName) => - displayName.StartsWith(softwareName + " ", StringComparison.OrdinalIgnoreCase) - ? displayName[(softwareName.Length + 1)..] - : displayName; - - private Control CreateSoftwarePanel(SoftwareDefinition definition) - { - var panel = new BorderedTableLayoutPanel - { - AutoSize = true, - AutoSizeMode = AutoSizeMode.GrowAndShrink, - Dock = DockStyle.Top, - ColumnCount = 2, - Padding = new Padding(14, 12, 14, 12), - Margin = new Padding(0, 8, 0, 0), - BackColor = Color.FromArgb(248, 250, 252), - BorderColor = Color.FromArgb(226, 232, 240), - }; - panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); - panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); - var title = new Label - { - Text = definition.DisplayName, - AutoSize = true, - Font = new Font("Microsoft YaHei UI", 10, FontStyle.Bold), - ForeColor = Color.FromArgb(15, 23, 42), - Margin = new Padding(0, 0, 0, 4), - }; - panel.Controls.Add(title, 0, 0); - var path = CreateTextBox(); - path.ReadOnly = true; - path.Dock = DockStyle.Fill; - path.Margin = new Padding(0, 7, 0, 10); - path.BackColor = Color.White; - panel.Controls.Add(path, 0, 1); - panel.SetColumnSpan(path, 2); - var status = CreateBodyLabel(); - status.Dock = DockStyle.Fill; - status.Margin = new Padding(0, 3, 0, 0); - panel.Controls.Add(status, 0, 2); - panel.SetColumnSpan(status, 2); - var actions = CreateActions(); - actions.Margin = new Padding(0, 10, 0, 0); - var automatic = CreateButton("自动检测", 92); - Button? choose = null; - if (definition.PathKind != SoftwarePathKind.None) - { - choose = CreateButton("选择位置", 92); - actions.Controls.Add(choose); - } - var install = CreateButton("安装 / 更新环境", 150, primary: true); - actions.Controls.Add(automatic); - actions.Controls.Add(install); - panel.Controls.Add(actions, 0, 3); - panel.SetColumnSpan(actions, 2); - var controls = new SoftwareControls(path, status, automatic, choose, install); - softwareControls.Add(definition.Id, controls); - automatic.Click += (_, _) => ResetSoftwareLocation(definition); - if (choose is not null) - { - choose.Click += (_, _) => ChooseSoftwareLocation(definition); - } - install.Click += async (_, _) => await InstallSoftwareRuntimeAsync(definition); - return panel; - } - - private void RefreshSoftwareCards() - { - foreach (var definition in SoftwareRuntimeManager.Definitions) - { - RefreshSoftwareCard(definition); - } - } - - private void RefreshSoftwareCard(SoftwareDefinition definition) - { - var controls = softwareControls[definition.Id]; - var location = SoftwareRuntimeManager.ResolveLocation(definition); - controls.Path.Text = location.Path ?? "未检测到应用位置"; - controls.Path.ForeColor = location.Available ? Color.FromArgb(51, 65, 85) : Color.Firebrick; - var runtime = Path.Combine( - NodePaths.ForCurrentMachine().RootDirectory, - "runtimes", - definition.RuntimeId, - "Scripts", - "python.exe"); - var runtimeState = File.Exists(runtime) ? "运行环境已安装" : "运行环境尚未安装"; - controls.Status.Text = location.Available - ? $"{runtimeState} · {location.Source}" - : $"{runtimeState} · {location.Detail}"; - controls.Status.ForeColor = location.Available ? Color.FromArgb(71, 85, 105) : Color.Firebrick; - controls.Install.Enabled = location.Available && runtimeInstallStop is null; - controls.Automatic.Enabled = runtimeInstallStop is null; - if (controls.Choose is not null) controls.Choose.Enabled = runtimeInstallStop is null; - } - - private void ResetSoftwareLocation(SoftwareDefinition definition) - { - try - { - SoftwareRuntimeManager.ClearConfiguredPath(definition); - adapters.InvalidateRuntime(definition.RuntimeId); - RefreshSoftwareCard(definition); - RefreshCapabilitySummary(); - } - catch (Exception exception) when ( - exception is IOException or SecurityException or UnauthorizedAccessException) - { - MessageBox.Show(exception.Message, "自动检测失败", - MessageBoxButtons.OK, MessageBoxIcon.Error); - } - } - - private void ChooseSoftwareLocation(SoftwareDefinition definition) - { - string? selected = null; - if (definition.PathKind == SoftwarePathKind.Executable) - { - using var dialog = new OpenFileDialog - { - Title = $"选择 {definition.DisplayName} 可执行文件", - Filter = $"{definition.RequiredRelativePath}|{definition.RequiredRelativePath}|可执行文件 (*.exe)|*.exe", - CheckFileExists = true, - Multiselect = false, - }; - if (dialog.ShowDialog(this) == DialogResult.OK) selected = dialog.FileName; - } - else if (definition.PathKind == SoftwarePathKind.Directory) - { - using var dialog = new FolderBrowserDialog - { - Description = $"选择 {definition.DisplayName} 安装根目录", - UseDescriptionForTitle = true, - ShowNewFolderButton = false, - }; - if (dialog.ShowDialog(this) == DialogResult.OK) selected = dialog.SelectedPath; - } - if (selected is null) return; - try - { - SoftwareRuntimeManager.SaveConfiguredPath(definition, selected); - adapters.InvalidateRuntime(definition.RuntimeId); - RefreshSoftwareCard(definition); - RefreshCapabilitySummary(); - } - catch (Exception exception) when ( - exception is ArgumentException - or IOException - or InvalidDataException - or InvalidOperationException - or SecurityException - or UnauthorizedAccessException) - { - MessageBox.Show(exception.Message, "应用位置无效", - MessageBoxButtons.OK, MessageBoxIcon.Error); - } - } - - private async Task InstallSoftwareRuntimeAsync(SoftwareDefinition definition) - { - if (adapters.All.Any(item => - definition.RuntimeId.Equals(item.RuntimeId, StringComparison.Ordinal) - && item.HasActiveJobs)) - { - MessageBox.Show( - $"{definition.DisplayName} 仍有任务在执行,暂不能更换运行环境。", - "暂不能安装", MessageBoxButtons.OK, MessageBoxIcon.Warning); - return; - } - var answer = MessageBox.Show( - $"将为 {definition.DisplayName} 创建独立 Python 3.12 运行环境并安装固定依赖。是否继续?", - "安装专业软件运行环境", MessageBoxButtons.YesNo, MessageBoxIcon.Question, - MessageBoxDefaultButton.Button2); - if (answer != DialogResult.Yes) return; - - runtimeInstallStop = new CancellationTokenSource(); - RefreshSoftwareCards(); - var controls = softwareControls[definition.Id]; - controls.Status.ForeColor = Color.DarkOrange; - var progress = new Progress(line => controls.Status.Text = line); - try - { - var result = await SoftwareRuntimeManager.InstallRuntimeAsync( - definition, - progress, - runtimeInstallStop.Token); - adapters.InvalidateRuntime(definition.RuntimeId); - controls.Status.ForeColor = Color.ForestGreen; - controls.Status.Text = result.Detail; - MessageBox.Show( - $"{result.Detail}\n{result.RuntimePath}", - "安装完成", MessageBoxButtons.OK, MessageBoxIcon.Information); - } - catch (OperationCanceledException) - { - controls.Status.ForeColor = Color.DimGray; - controls.Status.Text = "安装已取消,原运行环境保持不变。"; - } - catch (Exception exception) when ( - exception is IOException - or InvalidDataException - or InvalidOperationException - or SecurityException - or UnauthorizedAccessException - or System.ComponentModel.Win32Exception) - { - controls.Status.ForeColor = Color.Firebrick; - controls.Status.Text = $"安装失败:{exception.Message}"; - MessageBox.Show(exception.Message, "安装失败", - MessageBoxButtons.OK, MessageBoxIcon.Error); - } - finally - { - runtimeInstallStop.Dispose(); - runtimeInstallStop = null; - RefreshSoftwareCards(); - RefreshCapabilitySummary(); - } - } - - private void RefreshCapabilitySummary() - { - capabilitySummary.Text = currentConfig is null - ? "注册后启用" - : FormatCapabilitySummary(adapters.All); - } - - private void CopyDiagnostics() - { - if (currentConfig is null) - { - return; - } - try - { - Clipboard.SetText(BuildDiagnosticText(currentConfig)); - MessageBox.Show( - "诊断信息已复制,可直接粘贴到 Codex 对话。内容不包含 Node Token。", - "已复制", MessageBoxButtons.OK, MessageBoxIcon.Information); - } - catch (ExternalException exception) - { - MessageBox.Show( - $"无法写入剪贴板:{exception.Message}", - "复制失败", MessageBoxButtons.OK, MessageBoxIcon.Error); - } - } - - private string BuildDiagnosticText(NodeConfig config) - { - var paths = NodePaths.ForCurrentMachine(); - var builder = new StringBuilder() - .AppendLine("zcbot Windows Node diagnostics") - .AppendLine($"Node: {config.NodeName}") - .AppendLine($"Node ID: {config.NodeId}") - .AppendLine($"Server: {config.ServerUrl}") - .AppendLine($"Node version: {Application.ProductVersion}") - .AppendLine($"OS: {Environment.OSVersion.VersionString}") - .AppendLine($"Data root: {paths.RootDirectory}") - .AppendLine($"ANSYS gate: {AnsysGateState()}"); - foreach (var adapter in adapters.All.OrderBy(item => item.Capability, StringComparer.Ordinal)) - { - try - { - var runtime = adapter.DetectRuntime(); - builder.AppendLine() - .AppendLine($"Capability: {adapter.Capability}") - .AppendLine($"Adapter: {adapter.AdapterVersion}") - .AppendLine($"Software: {runtime.Software} {runtime.SoftwareVersion ?? "unknown"}") - .AppendLine($"Health: {runtime.Health}") - .AppendLine($"Detail: {runtime.Detail}") - .AppendLine($"Runtime: {adapter.RuntimePath}") - .AppendLine($"Contract: {adapter.ContractPath}") - .AppendLine($"Contract SHA-256: {adapter.ContractSha256}") - .AppendLine( - $"Workspace protocol: {(adapter.WorkspaceStateFilename is null ? "disabled" : "v1")}") - .AppendLine( - $"Workspace state: {adapter.WorkspaceStateFilename ?? "none"}"); - } - catch (Exception exception) when ( - exception is IOException - or InvalidOperationException - or UnauthorizedAccessException) - { - builder.AppendLine() - .AppendLine($"Capability: {adapter.Capability}") - .AppendLine($"Adapter: {adapter.AdapterVersion}") - .AppendLine($"Diagnostic error: {exception.Message}"); - } - } - return builder.ToString(); - } - - private async Task RunAnsysAcceptanceAsync() - { - var adapter = adapters.Find("ansys.mechanical.static_structural@v2"); - if (adapter?.SupportsLocalAcceptance != true) - { - MessageBox.Show( - "当前安装包没有 ANSYS 验收工具。请先安装新版完整 Node 包。", - "无法验收", MessageBoxButtons.OK, MessageBoxIcon.Error); - return; - } - using var outputDialog = new FolderBrowserDialog - { - Description = "选择保存 ANSYS 验收报告的父目录", - UseDescriptionForTitle = true, - ShowNewFolderButton = true, - }; - if (outputDialog.ShowDialog(this) != DialogResult.OK) - { - return; - } - var answer = MessageBox.Show( - "程序会使用内置标准试件执行一次进程树取消和连续 20 次真实静力求解,期间会获取并释放 ANSYS 许可证,可能耗时较长。确认开始?", - "开始 ANSYS 真机验收", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, - MessageBoxDefaultButton.Button2); - if (answer != DialogResult.Yes) - { - return; - } - - var workRoot = Path.Combine( - outputDialog.SelectedPath, - $"zcbot-ansys-acceptance-{DateTime.Now:yyyyMMdd-HHmmss}"); - acceptanceStop?.Dispose(); - acceptanceStop = new CancellationTokenSource(); - runAnsysAcceptance.Enabled = false; - enableAnsysGate.Enabled = false; - ansysAcceptanceProgress.Visible = true; - ansysAcceptanceStatus.ForeColor = Color.DarkOrange; - ansysAcceptanceStatus.Text = "正在启动真机验收…"; - var progress = new Progress(line => - { - ansysAcceptanceStatus.Text = line; - }); - try - { - var result = await adapter.RunLocalAcceptanceAsync( - workRoot, - progress, - acceptanceStop.Token); - passedAcceptanceReport = result.Passed ? result.ReportPath : null; - enableAnsysGate.Enabled = result.Passed && AnsysGateState() != "enabled"; - ansysAcceptanceStatus.ForeColor = result.Passed ? Color.ForestGreen : Color.Firebrick; - ansysAcceptanceStatus.Text = result.Passed - ? $"验收通过。报告:{result.ReportPath}\n请确认许可证管理端席位已释放,再开启执行门。" - : $"验收未通过:{result.Detail}\n报告:{result.ReportPath}"; - } - catch (OperationCanceledException) - { - passedAcceptanceReport = null; - ansysAcceptanceStatus.ForeColor = Color.DimGray; - ansysAcceptanceStatus.Text = "验收已停止,执行门保持关闭。"; - } - catch (Exception exception) when ( - exception is IOException - or InvalidDataException - or InvalidOperationException - or JsonException - or UnauthorizedAccessException) - { - passedAcceptanceReport = null; - ansysAcceptanceStatus.ForeColor = Color.Firebrick; - ansysAcceptanceStatus.Text = $"验收启动或执行失败:{exception.Message}"; - } - finally - { - ansysAcceptanceProgress.Visible = false; - runAnsysAcceptance.Enabled = true; - } - } - - private void EnableAnsysGate() - { - if (passedAcceptanceReport is null || !AcceptanceReportPassed(passedAcceptanceReport)) - { - MessageBox.Show( - "没有可验证的本次验收通过报告,执行门不会开启。", - "无法开启", MessageBoxButtons.OK, MessageBoxIcon.Warning); - return; - } - var answer = MessageBox.Show( - "请先在许可证管理端确认正常求解和取消后的席位均已释放。开启后需要完全退出并重新启动 Node 才会进入实际调度。确认开启机器级执行门?", - "开启 ANSYS 执行门", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, - MessageBoxDefaultButton.Button2); - if (answer != DialogResult.Yes) - { - return; - } - try - { - Environment.SetEnvironmentVariable( - "ZCBOT_ANSYS_242_VALIDATED", "1", EnvironmentVariableTarget.Machine); - enableAnsysGate.Enabled = false; - ansysAcceptanceStatus.ForeColor = Color.ForestGreen; - ansysAcceptanceStatus.Text = - "机器级执行门已写入。请从托盘退出 Node 后重新启动;重启后 ANSYS 应显示可用。"; - } - catch (Exception exception) when ( - exception is SecurityException or UnauthorizedAccessException) - { - MessageBox.Show( - $"写入机器级环境变量需要管理员权限:{exception.Message}\n请退出 Node,以管理员身份启动后再次点击。", - "权限不足", MessageBoxButtons.OK, MessageBoxIcon.Error); - } - } - - private static bool AcceptanceReportPassed(string path) - { - try - { - using var document = JsonDocument.Parse(File.ReadAllBytes(path)); - return document.RootElement.TryGetProperty("passed", out var passed) - && passed.ValueKind == JsonValueKind.True; - } - catch (Exception exception) when ( - exception is IOException or JsonException or UnauthorizedAccessException) - { - return false; - } - } - - private void UpdateAnsysAcceptanceStatus() - { - if (!ansysAcceptanceCard.Visible || ansysAcceptanceProgress.Visible) - { - return; - } - var gate = AnsysGateState(); - if (passedAcceptanceReport is not null - && AcceptanceReportPassed(passedAcceptanceReport)) - { - ansysAcceptanceStatus.ForeColor = Color.ForestGreen; - ansysAcceptanceStatus.Text = gate == "enabled" - ? "当前执行门:已开启。请完全退出并重新启动 Node,使新环境生效。" - : $"验收通过。报告:{passedAcceptanceReport}\n请确认许可证管理端席位已释放,再开启执行门。"; - enableAnsysGate.Enabled = gate != "enabled"; - return; - } - ansysAcceptanceStatus.ForeColor = gate == "enabled" - ? Color.ForestGreen - : Color.FromArgb(71, 85, 105); - ansysAcceptanceStatus.Text = gate == "enabled" - ? "当前执行门:已开启。Node 重启并且 probe 正常后可接收 ANSYS 任务。" - : "当前执行门:关闭。安装成功不代表验收通过,请先运行本页验收。"; - enableAnsysGate.Enabled = false; - } - - private static string AnsysGateState() => - Environment.GetEnvironmentVariable( - "ZCBOT_ANSYS_242_VALIDATED", EnvironmentVariableTarget.Machine) == "1" - ? "enabled" - : "disabled"; - - private void RefreshJobs() - { - Guid? selectedId = jobGrid.SelectedRows.Count > 0 - && jobGrid.SelectedRows[0].Tag is JobDisplaySnapshot selected - ? selected.JobId - : null; - IReadOnlyList snapshots; - try - { - snapshots = jobInbox.ReadJobSnapshots(); - } - catch (Exception exception) when ( - exception is IOException or UnauthorizedAccessException) - { - jobSummary.Text = $"无法读取本机任务:{exception.Message}"; - return; - } - jobGrid.Rows.Clear(); - DataGridViewRow? rowToSelect = null; - foreach (var snapshot in snapshots) - { - var index = jobGrid.Rows.Add( - FormatJobStage(snapshot.Stage), - snapshot.Title, - snapshot.InputFilename, - FormatProgress(snapshot), - snapshot.AcceptedAt.LocalDateTime.ToString("MM-dd HH:mm"), - snapshot.JobId.ToString("N")[..8]); - var row = jobGrid.Rows[index]; - row.Tag = snapshot; - row.DefaultCellStyle.ForeColor = snapshot.Stage switch - { - "failed" => Color.Firebrick, - "cloud_terminal" => Color.Firebrick, - "cancelled" => Color.DimGray, - "succeeded" => Color.ForestGreen, - _ => Color.FromArgb(30, 41, 59), - }; - if (snapshot.JobId == selectedId) - { - rowToSelect = row; - } - } - var active = snapshots.Count(item => item.IsActive); - jobSummary.Text = snapshots.Count == 0 - ? "暂无本机任务" - : $"活动任务 {active} 个 · 最近记录 {snapshots.Count} 条"; - if (rowToSelect is not null) - { - rowToSelect.Selected = true; - jobGrid.CurrentCell = rowToSelect.Cells[0]; - } - else if (jobGrid.Rows.Count > 0) - { - jobGrid.Rows[0].Selected = true; - jobGrid.CurrentCell = jobGrid.Rows[0].Cells[0]; - } - else - { - jobDetail.Text = "选择任务后可查看执行阶段、更新时间和完整 Job ID。"; - } - ShowSelectedJob(); - } - - private void ShowSelectedJob() - { - if (jobGrid.SelectedRows.Count == 0 - || jobGrid.SelectedRows[0].Tag is not JobDisplaySnapshot snapshot) - { - return; - } - jobDetail.Text = string.Join("\n", [ - $"{FormatJobStage(snapshot.Stage)} · {snapshot.Detail}", - $"能力:{snapshot.Capability}", - $"Job ID:{snapshot.JobId}", - $"更新时间:{snapshot.UpdatedAt.LocalDateTime:yyyy-MM-dd HH:mm:ss}", - ]); - } - - private static string FormatProgress(JobDisplaySnapshot snapshot) => - snapshot.Stage == "software_running" - ? $"已执行 {FormatElapsed(DateTimeOffset.UtcNow - snapshot.UpdatedAt)}" - : $"{snapshot.Progress}%"; - - private static string FormatElapsed(TimeSpan elapsed) => elapsed.TotalHours >= 1 - ? $"{(int)elapsed.TotalHours}:{elapsed.Minutes:00}:{elapsed.Seconds:00}" - : $"{elapsed.Minutes:00}:{elapsed.Seconds:00}"; - - private static string FormatJobStage(string stage) => stage switch - { - "accepted" => "等待处理", - "downloading_inputs" => "下载输入", - "ready_to_run" => "准备执行", - "software_running" => "软件执行中", - "uploading_outputs" => "上传结果", - "succeeded" => "成功", - "failed" => "失败", - "cloud_terminal" => "云端已终止", - "cancelled" => "已取消", - _ => stage, - }; - - private void ResetIdentity() - { - var answer = MessageBox.Show( - "这会删除本机加密身份,随后需要使用新注册码重新注册。请先在管理后台删除或禁用云端旧节点。是否继续?", - "清除本机节点身份", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, - MessageBoxDefaultButton.Button2); - if (answer == DialogResult.Yes) - { - ResetIdentityRequested?.Invoke(); - } - } - - private async Task RegisterAsync() - { - if (RegisterRequested is null) - { - return; - } - try - { - register.Enabled = false; - detail.Text = "正在注册…"; - var options = EnrollOptions.Parse([ - "--server", server.Text, "--name", nodeName.Text, "--code", enrollmentCode.Text]); - await RegisterRequested(options); - } - catch (Exception exception) when (exception is NodeConfigurationException or HttpRequestException) - { - MessageBox.Show(exception.Message, "注册失败", MessageBoxButtons.OK, MessageBoxIcon.Error); - register.Enabled = true; - } - } - - private void ToggleStartup() - { - if (changingStartup) - { - return; - } - try - { - StartupRegistration.SetEnabled(startAtLogin.Checked); - } - catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) - { - MessageBox.Show(exception.Message, "自动启动设置失败", MessageBoxButtons.OK, MessageBoxIcon.Error); - changingStartup = true; - try - { - startAtLogin.Checked = StartupRegistration.IsEnabled; - } - finally - { - changingStartup = false; - } - } - } - - private static TableLayoutPanel CreateSection() - { - var section = new TableLayoutPanel - { - AutoSize = true, - AutoSizeMode = AutoSizeMode.GrowAndShrink, - Dock = DockStyle.Top, - ColumnCount = 1, - Padding = new Padding(4, 16, 4, 12), - Margin = new Padding(0), - BackColor = Color.White, - }; - section.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); - return section; - } - - private static TableLayoutPanel AddTab(TabControl tabs, string text) - { - var tab = new TabPage - { - Text = text, - AutoScroll = true, - BackColor = Color.White, - Padding = new Padding(12, 0, 12, 0), - UseVisualStyleBackColor = false, - }; - var content = new TableLayoutPanel - { - AutoSize = true, - AutoSizeMode = AutoSizeMode.GrowAndShrink, - Dock = DockStyle.Top, - ColumnCount = 1, - BackColor = tab.BackColor, - Margin = new Padding(0), - }; - content.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); - tab.Controls.Add(content); - tabs.TabPages.Add(tab); - return content; - } - - private static Label CreateSectionTitle(string text) => new() - { - Text = text, - AutoSize = true, - Font = new Font("Microsoft YaHei UI", 11, FontStyle.Bold), - ForeColor = Color.FromArgb(30, 41, 59), - Margin = new Padding(0, 0, 0, 8), - }; - - private static Label CreateSubsectionTitle(string text) => new() - { - Text = text, - AutoSize = true, - Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold), - ForeColor = Color.FromArgb(51, 65, 85), - Margin = new Padding(0, 0, 0, 7), - }; - - private static Label CreateBodyLabel() => new() - { - AutoSize = true, - Dock = DockStyle.Fill, - ForeColor = Color.FromArgb(71, 85, 105), - Margin = new Padding(0, 2, 0, 5), - }; - - private static Label CreateHint(string text) => new() - { - Text = text, - AutoSize = true, - Dock = DockStyle.Fill, - ForeColor = Color.FromArgb(100, 116, 139), - Margin = new Padding(0, 2, 0, 4), - }; - - private static DataGridView CreateJobGrid() - { - var grid = new DataGridView - { - Height = 238, - Dock = DockStyle.Top, - Margin = new Padding(0, 8, 0, 8), - BackgroundColor = Color.White, - BorderStyle = BorderStyle.FixedSingle, - AllowUserToAddRows = false, - AllowUserToDeleteRows = false, - AllowUserToResizeRows = false, - AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.None, - ColumnHeadersHeight = 34, - EnableHeadersVisualStyles = false, - MultiSelect = false, - ReadOnly = true, - RowHeadersVisible = false, - RowTemplate = { Height = 34 }, - SelectionMode = DataGridViewSelectionMode.FullRowSelect, - }; - grid.ColumnHeadersDefaultCellStyle.BackColor = Color.FromArgb(241, 245, 249); - grid.ColumnHeadersDefaultCellStyle.ForeColor = Color.FromArgb(51, 65, 85); - grid.DefaultCellStyle.SelectionBackColor = Color.FromArgb(219, 234, 254); - grid.DefaultCellStyle.SelectionForeColor = Color.FromArgb(30, 64, 175); - grid.Columns.Add(new DataGridViewTextBoxColumn - { - HeaderText = "状态", - Width = 105, - }); - grid.Columns.Add(new DataGridViewTextBoxColumn - { - HeaderText = "任务", - AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill, - MinimumWidth = 170, - }); - grid.Columns.Add(new DataGridViewTextBoxColumn - { - HeaderText = "输入", - Width = 125, - }); - grid.Columns.Add(new DataGridViewTextBoxColumn - { - HeaderText = "进度", - Width = 95, - }); - grid.Columns.Add(new DataGridViewTextBoxColumn - { - HeaderText = "接收时间", - Width = 100, - }); - grid.Columns.Add(new DataGridViewTextBoxColumn - { - HeaderText = "Job ID", - Width = 76, - }); - 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, - Dock = DockStyle.Top, - BackColor = Color.FromArgb(226, 232, 240), - Margin = new Padding(0, 10, 0, 12), - }; - - private static TextBox CreateTextBox(string text = "", bool usePassword = false) => new() - { - Text = text, - UseSystemPasswordChar = usePassword, - AutoSize = false, - Height = 34, - BorderStyle = BorderStyle.FixedSingle, - Font = new Font("Segoe UI", 10), - }; - - private static Button CreateButton( - string text, - int width, - bool primary = false, - bool danger = false) - { - var button = new Button - { - Text = text, - AutoSize = false, - Size = new Size(width, 38), - FlatStyle = FlatStyle.Flat, - BackColor = primary ? Color.FromArgb(37, 99, 235) : Color.White, - ForeColor = primary - ? Color.White - : danger ? Color.FromArgb(185, 28, 28) : Color.FromArgb(51, 65, 85), - Font = new Font("Microsoft YaHei UI", 9), - Margin = new Padding(0, 0, 10, 0), - Cursor = Cursors.Hand, - }; - button.FlatAppearance.BorderColor = primary - ? Color.FromArgb(37, 99, 235) - : danger ? Color.FromArgb(254, 202, 202) : Color.FromArgb(203, 213, 225); - button.FlatAppearance.MouseOverBackColor = primary - ? Color.FromArgb(29, 78, 216) - : danger ? Color.FromArgb(254, 242, 242) : Color.FromArgb(248, 250, 252); - return button; - } - - private static FlowLayoutPanel CreateActions() => new() - { - AutoSize = true, - FlowDirection = FlowDirection.LeftToRight, - WrapContents = true, - Dock = DockStyle.Fill, - Margin = new Padding(0, 10, 0, 0), - }; - - private static void AddField( - TableLayoutPanel layout, string label, Control control, string hint) - { - layout.Controls.Add(new Label - { - Text = label, - AutoSize = true, - Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold), - ForeColor = Color.FromArgb(51, 65, 85), - Margin = new Padding(0, 8, 0, 4), - }); - control.Dock = DockStyle.Top; - control.Margin = new Padding(0, 0, 0, 2); - layout.Controls.Add(control); - layout.Controls.Add(CreateHint(hint)); - } - - private sealed record SoftwareControls( - TextBox Path, - Label Status, - Button Automatic, - Button? Choose, - Button Install); - - private sealed record CapabilitySummaryEntry( - string SoftwareName, - string? SoftwareVersion, - string CapabilityName, - bool Ready); - - private sealed class BorderedTableLayoutPanel : TableLayoutPanel - { - internal Color BorderColor = Color.FromArgb(226, 232, 240); - - internal BorderedTableLayoutPanel() - { - DoubleBuffered = true; - } - - protected override void OnPaint(PaintEventArgs eventArgs) - { - base.OnPaint(eventArgs); - using var pen = new Pen(BorderColor); - eventArgs.Graphics.DrawRectangle( - pen, - 0, - 0, - Math.Max(0, ClientSize.Width - 1), - Math.Max(0, ClientSize.Height - 1)); - } - } - - private sealed class NavigationTabControl : TabControl - { - internal NavigationTabControl() - { - Appearance = TabAppearance.FlatButtons; - DrawMode = TabDrawMode.OwnerDrawFixed; - HotTrack = true; - SetStyle(ControlStyles.OptimizedDoubleBuffer, true); - } - - protected override void OnDrawItem(DrawItemEventArgs eventArgs) - { - var selected = eventArgs.Index == SelectedIndex; - var bounds = eventArgs.Bounds; - using var background = new SolidBrush(Color.White); - eventArgs.Graphics.FillRectangle(background, bounds); - TextRenderer.DrawText( - eventArgs.Graphics, - TabPages[eventArgs.Index].Text, - Font, - bounds, - selected ? Color.FromArgb(29, 78, 216) : Color.FromArgb(71, 85, 105), - TextFormatFlags.HorizontalCenter - | TextFormatFlags.VerticalCenter - | TextFormatFlags.SingleLine); - if (selected) - { - using var accent = new SolidBrush(Color.FromArgb(37, 99, 235)); - eventArgs.Graphics.FillRectangle( - accent, - bounds.Left + 12, - bounds.Bottom - 3, - Math.Max(0, bounds.Width - 24), - 3); - } - } - } -} diff --git a/windows-node/Zcbot.WindowsNode/EnrollmentClient.cs b/windows-node/Zcbot.WindowsNode/EnrollmentClient.cs index 1d13465..9b150c9 100644 --- a/windows-node/Zcbot.WindowsNode/EnrollmentClient.cs +++ b/windows-node/Zcbot.WindowsNode/EnrollmentClient.cs @@ -7,7 +7,7 @@ namespace Zcbot.WindowsNode; internal static class EnrollmentClient { internal static async Task EnrollAsync( - EnrollOptions options, NodeConfigStore store, CancellationToken cancellationToken) + EnrollOptions options, INodeConfigStore store, CancellationToken cancellationToken) { if (store.Exists) { @@ -22,7 +22,7 @@ internal static class EnrollmentClient installId, Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.1.0", RuntimeInformation.OSDescription, - NodeAdapterRegistry.InstalledCapabilities); + AdapterCatalog.InstalledCapabilities); using var client = new HttpClient { BaseAddress = options.ServerUrl, Timeout = TimeSpan.FromSeconds(30) }; using var response = await client.PostAsJsonAsync( @@ -43,7 +43,7 @@ internal static class EnrollmentClient store.Save(new NodeConfig( options.ServerUrl, enrolled.NodeId, installId, options.NodeName, enrolled.NodeToken, Math.Clamp(enrolled.HeartbeatSeconds, 5, 300), - NodeAdapterRegistry.InstalledCapabilities)); + AdapterCatalog.InstalledCapabilities)); } private static string Limit(string value, int maxLength) => diff --git a/windows-node/Zcbot.WindowsNode/GlobalUsings.cs b/windows-node/Zcbot.WindowsNode/GlobalUsings.cs new file mode 100644 index 0000000..6811849 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using System.IO; +global using System.Net.Http; diff --git a/windows-node/Zcbot.WindowsNode/Host/NodeConnectionLoop.cs b/windows-node/Zcbot.WindowsNode/Host/NodeConnectionLoop.cs new file mode 100644 index 0000000..9d89433 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Host/NodeConnectionLoop.cs @@ -0,0 +1,43 @@ +namespace Zcbot.WindowsNode; + +internal sealed class NodeConnectionLoop : INodeConnection +{ + private readonly JobCoordinator coordinator; + private readonly NodeSession session; + + internal NodeConnectionLoop( + NodeConfig config, + NodePaths paths, + Action? statusChanged = null) + { + coordinator = new JobCoordinator(config, paths); + session = new NodeSession( + config, + coordinator.RuntimePayload, + coordinator.ReportRecoverableJobsAsync, + coordinator.ResumeDeferredUploadsAsync, + coordinator.HandleMessageAsync, + (state, message) => statusChanged?.Invoke(NodeStatus.Create(state, message))); + } + + public async Task RunAsync(CancellationToken cancellationToken) + { + try + { + await session.RunAsync(cancellationToken); + } + finally + { + try + { + await coordinator.WaitForIdleAsync(); + } + finally + { + coordinator.Dispose(); + } + } + } + + public void CancelActiveJobsForExit() => coordinator.CancelActiveJobsForExit(); +} diff --git a/windows-node/Zcbot.WindowsNode/Host/NodeSession.cs b/windows-node/Zcbot.WindowsNode/Host/NodeSession.cs new file mode 100644 index 0000000..d3e523b --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Host/NodeSession.cs @@ -0,0 +1,134 @@ +using System.Net.WebSockets; +using System.Text.Json; + +namespace Zcbot.WindowsNode; + +internal sealed class NodeSession +{ + private static readonly TimeSpan[] Backoff = + [ + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(60), + ]; + + private readonly NodeConfig config; + private readonly Func runtimePayload; + private readonly Func connected; + private readonly Func heartbeat; + private readonly Func received; + private readonly Action report; + private readonly Func> connect; + private readonly Func delay; + + internal NodeSession( + NodeConfig config, + Func runtimePayload, + Func connected, + Func heartbeat, + Func received, + Action report, + Func>? connect = null, + Func? delay = null) + { + this.config = config; + this.runtimePayload = runtimePayload; + this.connected = connected; + this.heartbeat = heartbeat; + this.received = received; + this.report = report; + this.connect = connect ?? NodeProtocolClient.ConnectAsync; + this.delay = delay ?? Task.Delay; + } + + internal async Task RunAsync(CancellationToken cancellationToken) + { + var attempt = 0; + while (!cancellationToken.IsCancellationRequested) + { + try + { + report(NodeState.Connecting, "正在连接 zcbot…"); + await ConnectOnceAsync(cancellationToken); + attempt = 0; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (WebSocketException exception) + { + report(NodeState.Offline, "连接中断,等待重连"); + Console.Error.WriteLine($"[WARN] WebSocket disconnected: {exception.Message}"); + } + catch (IOException exception) + { + report(NodeState.Offline, "网络不可用,等待重连"); + Console.Error.WriteLine($"[WARN] Connection I/O failed: {exception.Message}"); + } + catch (JsonException exception) + { + report(NodeState.Offline, "服务端消息无效,等待重连"); + Console.Error.WriteLine($"[WARN] Invalid server message: {exception.Message}"); + } + catch (NodeEndpointException exception) + { + report(NodeState.Offline, "WebSocket 握手被拒绝,请检查服务端或反向代理"); + Console.Error.WriteLine($"[WARN] WebSocket handshake rejected: {exception.Message}"); + } + + var baseDelay = Backoff[Math.Min(attempt, Backoff.Length - 1)]; + attempt++; + var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(0, 750)); + var delay = baseDelay + jitter; + Console.WriteLine($"[INFO] Reconnecting in {delay.TotalSeconds:F1}s."); + await this.delay(delay, cancellationToken); + } + } + + private async Task ConnectOnceAsync(CancellationToken cancellationToken) + { + await using var client = await connect(config, cancellationToken); + Console.WriteLine("[OK] Node connected."); + report(NodeState.Online, "已连接"); + + await client.SendAsync("hello", runtimePayload(), cancellationToken); + await connected(client, cancellationToken); + using var heartbeatStop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var heartbeatTask = HeartbeatLoopAsync(client, heartbeatStop.Token); + try + { + while (await client.ReceiveAsync(cancellationToken) is { } message) + { + Console.WriteLine($"[INFO] Server message: {message.Type}."); + await received(client, message, cancellationToken); + } + } + finally + { + heartbeatStop.Cancel(); + try + { + await heartbeatTask; + } + catch (OperationCanceledException) when (heartbeatStop.IsCancellationRequested) + { + } + } + } + + private async Task HeartbeatLoopAsync( + NodeProtocolClient client, + CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(config.HeartbeatSeconds)); + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + await client.SendAsync("heartbeat", runtimePayload(), cancellationToken); + await heartbeat(client, cancellationToken); + } + } +} diff --git a/windows-node/Zcbot.WindowsNode/JobInboxStore.cs b/windows-node/Zcbot.WindowsNode/JobInboxStore.cs index 8ed2aa8..c48feb6 100644 --- a/windows-node/Zcbot.WindowsNode/JobInboxStore.cs +++ b/windows-node/Zcbot.WindowsNode/JobInboxStore.cs @@ -5,204 +5,26 @@ namespace Zcbot.WindowsNode; internal sealed class JobInboxStore(string jobsDirectory) { - private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + internal static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private static readonly HashSet XyzPlotTypes = ["contour", "surface_3d", "ternary", "heatmap"]; - private static readonly HashSet JobStages = - [ - "accepted", - "downloading_inputs", - "ready_to_run", - "software_running", - "uploading_outputs", - "succeeded", - "failed", - "cancelled", - ]; // Origin 执行槽只由尚无终态的任务占用。成功但上传确认尚未落盘的任务会由 // 心跳恢复管线继续重传;上传不使用 Origin,不能反向阻塞新的绘图任务。 - internal bool HasPendingJobs => Directory.Exists(jobsDirectory) - && ReadRecoverableJobs().Any(item => item.Terminal is null); + internal bool HasPendingJobs => new JobRepository(jobsDirectory).HasPendingJobs; - internal IReadOnlyList ReadRecoverableJobs() - { - if (!Directory.Exists(jobsDirectory)) - { - return []; - } - var jobs = new List(); - foreach (var requestPath in Directory.EnumerateFiles( - jobsDirectory, "request.json", SearchOption.AllDirectories)) - { - try - { - using var request = JsonDocument.Parse(File.ReadAllBytes(requestPath)); - var root = request.RootElement; - if (!TryReadGuid(root, "job_id", out var jobId) - || !TryReadGuid(root, "lease_id", out var leaseId) - || !root.TryGetProperty("request_digest", out var digestValue) - || digestValue.GetString() is not { Length: 64 } requestDigest - || !root.TryGetProperty("capability", out var capabilityValue) - || capabilityValue.GetString() is not { Length: > 0 } capability - || !root.TryGetProperty("workspace", out var workspaceValue) - || !TryReadWorkspace(workspaceValue, out var workspace)) - { - continue; - } - var jobDirectory = Directory.GetParent(Directory.GetParent(requestPath)!.FullName)!.FullName; - jobs.Add(new RecoverableJob( - jobId, - leaseId, - requestDigest, - capability, - workspace, - root.TryGetProperty("input_transfers", out var transfers) - ? transfers.Clone() : null, - ReadTerminal(Path.Combine(jobDirectory, "terminal.json")), - File.Exists(Path.Combine(jobDirectory, "upload-complete.json")), - File.Exists(Path.Combine(jobDirectory, "cloud-terminal.json")))); - } - catch (Exception exception) when ( - exception is JsonException or IOException or UnauthorizedAccessException) - { - } - } - return jobs; - } + internal IReadOnlyList ReadRecoverableJobs() => + new JobRepository(jobsDirectory).ReadRecoverableJobs(); - internal IReadOnlyList ReadJobSnapshots(int limit = 50) - { - if (!Directory.Exists(jobsDirectory)) - { - return []; - } - var snapshots = new List(); - foreach (var requestPath in Directory.EnumerateFiles( - jobsDirectory, "request.json", SearchOption.AllDirectories)) - { - try - { - using var requestDocument = JsonDocument.Parse(File.ReadAllBytes(requestPath)); - var root = requestDocument.RootElement; - if (!TryReadGuid(root, "job_id", out var jobId)) - { - continue; - } - var jobDirectory = Directory.GetParent( - Directory.GetParent(requestPath)!.FullName)!.FullName; - var acceptedAt = ReadDate(root, "accepted_at") - ?? new DateTimeOffset(File.GetCreationTimeUtc(requestPath)); - var capability = ReadString(root, "capability", "unknown"); - var title = ReadDisplayTitle(root); - var inputFilename = root.TryGetProperty("input_transfers", out var transfers) - && transfers.ValueKind == JsonValueKind.Array - ? string.Join(", ", transfers.EnumerateArray() - .Select(item => ReadString(item, "filename", "-"))) - : "-"; - var state = ReadState(Path.Combine(jobDirectory, "state.json")); - var terminal = ReadTerminal(Path.Combine(jobDirectory, "terminal.json")); - var uploadPath = Path.Combine(jobDirectory, "upload-complete.json"); - var uploadComplete = File.Exists(uploadPath); - var cloudTerminalPath = Path.Combine(jobDirectory, "cloud-terminal.json"); - var cloudTerminal = ReadTerminal(cloudTerminalPath); - var stage = state?.Stage ?? "accepted"; - var progress = state?.Progress ?? 0; - var detail = state?.Detail ?? "任务已由本机接收"; - var updatedAt = state is not null - && state.UpdatedAt != DateTimeOffset.MinValue - && state.UpdatedAt > acceptedAt - ? state.UpdatedAt - : acceptedAt; - if (terminal is JsonElement terminalValue) - { - var terminalStatus = ReadString(terminalValue, "status", "failed"); - if (terminalStatus == "succeeded" && !uploadComplete) - { - stage = "uploading_outputs"; - progress = Math.Max(progress, 90); - detail = state?.Stage == "uploading_outputs" - ? state.Detail - : "软件执行完成,等待上传结果"; - } - else - { - stage = terminalStatus; - progress = terminalStatus == "succeeded" ? 100 : progress; - detail = TerminalDetail(terminalValue, terminalStatus, detail); - } - updatedAt = LatestWrite(updatedAt, Path.Combine(jobDirectory, "terminal.json")); - } - if (uploadComplete) - { - stage = "succeeded"; - progress = 100; - detail = File.Exists(Path.Combine(jobDirectory, "workspace-result.json")) - ? "预览已上传,工程保存在本机工作区" - : "结果已上传并由云端确认"; - updatedAt = LatestWrite(updatedAt, uploadPath); - } - else if (cloudTerminal is JsonElement cloudValue) - { - var cloudStatus = ReadString(cloudValue, "status", "failed"); - stage = "cloud_terminal"; - progress = 100; - detail = cloudStatus == "cancelled" - ? "云端任务已取消,本地生成的结果仍保留" - : "云端任务已终止,本地生成的结果仍保留"; - updatedAt = LatestWrite(updatedAt, cloudTerminalPath); - } - snapshots.Add(new JobDisplaySnapshot( - jobId, - capability, - title, - inputFilename, - stage, - Math.Clamp(progress, 0, 100), - detail, - acceptedAt, - updatedAt, - uploadComplete)); - } - catch (Exception exception) when ( - exception is JsonException or IOException or UnauthorizedAccessException) - { - } - } - return snapshots - .OrderByDescending(item => item.IsActive) - .ThenByDescending(item => item.UpdatedAt) - .Take(Math.Max(1, limit)) - .ToArray(); - } + internal void WriteState( + RecoverableJob job, + string stage, + int progress, + string detail, + JobTransitionMode mode = JobTransitionMode.Normal) => + new JobRepository(jobsDirectory).WriteState(job, stage, progress, detail, mode); - internal void WriteState(RecoverableJob job, string stage, int progress, string detail) - { - if (!JobStages.Contains(stage)) - { - throw new InvalidDataException("Unsupported local job stage."); - } - var path = Path.Combine( - jobsDirectory, job.JobId.ToString("D"), "state.json"); - var content = JsonSerializer.SerializeToUtf8Bytes(new - { - stage, - progress = Math.Clamp(progress, 0, 100), - detail = detail[..Math.Min(detail.Length, 500)], - updated_at = DateTimeOffset.UtcNow, - }, JsonOptions); - try - { - AtomicWrite(path, content, overwrite: true); - } - catch (Exception exception) when ( - exception is IOException or UnauthorizedAccessException) - { - Console.Error.WriteLine($"[WARN] Local job state update failed: {exception.Message}"); - } - } - - internal JobOfferResult Accept(JsonElement payload, NodeAdapterRegistry adapters) + internal JobOfferResult Accept(JsonElement payload, AdapterCatalog adapters) { if (!TryReadGuid(payload, "job_id", out var jobId) || !TryReadGuid(payload, "lease_id", out var leaseId) @@ -238,7 +60,7 @@ internal sealed class JobInboxStore(string jobsDirectory) { return JobOfferResult.Reject("invalid_offer"); } - var directory = Path.Combine(jobsDirectory, jobId.ToString("D")); + var directory = PathGuard.CombineUnderRoot(jobsDirectory, jobId.ToString("D")); var requestDirectory = Path.Combine(directory, "request"); var requestPath = Path.Combine(requestDirectory, "request.json"); Directory.CreateDirectory(requestDirectory); @@ -274,7 +96,7 @@ internal sealed class JobInboxStore(string jobsDirectory) }, input_transfers = inputTransfers, }, JsonOptions); - AtomicWrite(requestPath, updated, overwrite: true); + AtomicFile.Write(requestPath, updated, overwrite: true); } EnsureAcceptedState(jobId); return JobOfferResult.Accept(jobId, leaseId, requestDigest); @@ -304,7 +126,7 @@ internal sealed class JobInboxStore(string jobsDirectory) }, JsonOptions); try { - AtomicWrite(requestPath, record, overwrite: false); + AtomicFile.Write(requestPath, record, overwrite: false); EnsureAcceptedState(jobId); return JobOfferResult.Accept(jobId, leaseId, requestDigest); } @@ -314,7 +136,7 @@ internal sealed class JobInboxStore(string jobsDirectory) } } - private static JsonElement? ReadTerminal(string path) + internal static JsonElement? ReadTerminal(string path) { if (!File.Exists(path)) { @@ -325,34 +147,9 @@ internal sealed class JobInboxStore(string jobsDirectory) } private void EnsureAcceptedState(Guid jobId) - { - var path = Path.Combine(jobsDirectory, jobId.ToString("D"), "state.json"); - if (File.Exists(path)) - { - return; - } - var content = JsonSerializer.SerializeToUtf8Bytes(new - { - stage = "accepted", - progress = 0, - detail = "任务已由本机接收", - updated_at = DateTimeOffset.UtcNow, - }, JsonOptions); - try - { - AtomicWrite(path, content, overwrite: false); - } - catch (IOException) when (File.Exists(path)) - { - } - catch (Exception exception) when ( - exception is IOException or UnauthorizedAccessException) - { - Console.Error.WriteLine($"[WARN] Initial local job state failed: {exception.Message}"); - } - } + => new JobRepository(jobsDirectory).EnsureAcceptedState(jobId); - private static LocalJobState? ReadState(string path) + internal static LocalJobState? ReadState(string path) { if (!File.Exists(path)) { @@ -380,31 +177,6 @@ internal sealed class JobInboxStore(string jobsDirectory) } } - private static string TerminalDetail(JsonElement terminal, string status, string fallback) - { - if (terminal.TryGetProperty("error", out var error)) - { - var detail = ReadString(error, "detail", ""); - if (!string.IsNullOrWhiteSpace(detail)) - { - return detail; - } - } - return status switch - { - "succeeded" => "软件任务执行成功", - "cancelled" => "任务已取消", - "failed" => "任务执行失败", - _ => fallback, - }; - } - - private static DateTimeOffset LatestWrite(DateTimeOffset current, string path) - { - var writtenAt = new DateTimeOffset(File.GetLastWriteTimeUtc(path)); - return writtenAt > current ? writtenAt : current; - } - private static string ReadString(JsonElement value, string name, string fallback) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String @@ -412,42 +184,6 @@ internal sealed class JobInboxStore(string jobsDirectory) ? property.GetString()! : fallback; - private static string ReadDisplayTitle(JsonElement root) - { - const string fallback = "未命名任务"; - if (root.TryGetProperty("request_summary", out var summary) - && summary.ValueKind == JsonValueKind.Object) - { - var title = ReadString(summary, "title", fallback); - if (title != fallback) - { - return title; - } - } - - // 兼容 request_summary 引入前已落盘的 Job。历史请求的 operation - // 下只有一个软件动作对象;遍历它而不是绑定 Origin/Blender 字段名。 - if (root.TryGetProperty("request", out var request) - && request.ValueKind == JsonValueKind.Object - && request.TryGetProperty("operation", out var operation) - && operation.ValueKind == JsonValueKind.Object) - { - foreach (var action in operation.EnumerateObject()) - { - if (action.Value.ValueKind != JsonValueKind.Object) - { - continue; - } - var title = ReadString(action.Value, "title", fallback); - if (title != fallback) - { - return title; - } - } - } - return fallback; - } - private static DateTimeOffset? ReadDate(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String @@ -455,27 +191,6 @@ internal sealed class JobInboxStore(string jobsDirectory) ? parsed : null; - private static void AtomicWrite(string path, byte[] content, bool overwrite) - { - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - var temporaryPath = path + ".tmp-" + Guid.NewGuid().ToString("N"); - try - { - using (var stream = new FileStream( - temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, - bufferSize: 4096, FileOptions.WriteThrough)) - { - stream.Write(content); - stream.Flush(flushToDisk: true); - } - File.Move(temporaryPath, path, overwrite); - } - finally - { - if (File.Exists(temporaryPath)) File.Delete(temporaryPath); - } - } - private static bool IsValidSelector(JsonElement selector) => selector.ValueKind == JsonValueKind.Object && HasOnlyProperties(selector, "sheet") @@ -491,7 +206,7 @@ internal sealed class JobInboxStore(string jobsDirectory) || character is >= '0' and <= '9' || character == '_'); - private static bool IsValidInputTransfers(JsonElement transfers, Guid jobId) + internal static bool IsValidInputTransfers(JsonElement transfers, Guid jobId) { if (transfers.ValueKind != JsonValueKind.Array || transfers.GetArrayLength() > 16) @@ -564,43 +279,14 @@ internal sealed class JobInboxStore(string jobsDirectory) } internal IReadOnlyList InputTransfers(RecoverableJob job) - { - if (job.InputTransfers is not JsonElement transfers - || !IsValidInputTransfers(transfers, job.JobId)) - { - throw new InvalidDataException("Stored input transfers are invalid."); - } - return transfers.EnumerateArray().Select(item => item.Clone()).ToArray(); - } + => new JobRepository(jobsDirectory).InputTransfers(job); internal string InputPath(RecoverableJob job, JsonElement transfer) - { - var key = transfer.GetProperty("key").GetString()!; - var filename = transfer.GetProperty("filename").GetString()!; - return Path.Combine( - jobsDirectory, job.JobId.ToString("D"), "input", key, filename); - } + => new JobRepository(jobsDirectory).InputPath(job, transfer); internal void WriteTerminal( RecoverableJob job, string status, string code, string detail, bool overwrite = false) - { - var path = Path.Combine(jobsDirectory, job.JobId.ToString("D"), "terminal.json"); - if (File.Exists(path) && !overwrite) - { - return; - } - var content = JsonSerializer.SerializeToUtf8Bytes(new - { - job_id = job.JobId, - lease_id = job.LeaseId, - request_digest = job.RequestDigest, - status, - error = new { code, detail }, - artifact_manifest = Array.Empty(), - terminal_at = DateTimeOffset.UtcNow, - }, JsonOptions); - AtomicWrite(path, content, overwrite); - } + => new JobRepository(jobsDirectory).WriteTerminal(job, status, code, detail, overwrite); private static bool HasOnlyProperties(JsonElement value, params string[] allowed) { @@ -608,7 +294,7 @@ internal sealed class JobInboxStore(string jobsDirectory) return value.EnumerateObject().All(item => names.Contains(item.Name)); } - private static bool TryReadGuid(JsonElement payload, string name, out Guid value) + internal static bool TryReadGuid(JsonElement payload, string name, out Guid value) { value = Guid.Empty; return payload.TryGetProperty(name, out var property) @@ -616,7 +302,7 @@ internal sealed class JobInboxStore(string jobsDirectory) && Guid.TryParse(property.GetString(), out value); } - private static bool TryReadWorkspace( + internal static bool TryReadWorkspace( JsonElement value, out WorkspaceBinding? workspace) { workspace = null; diff --git a/windows-node/Zcbot.WindowsNode/JobInputDownloader.cs b/windows-node/Zcbot.WindowsNode/JobInputDownloader.cs index 9a072fb..283e0a8 100644 --- a/windows-node/Zcbot.WindowsNode/JobInputDownloader.cs +++ b/windows-node/Zcbot.WindowsNode/JobInputDownloader.cs @@ -1,10 +1,9 @@ -using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text.Json; namespace Zcbot.WindowsNode; -internal sealed class JobInputDownloader(NodeConfig config, JobInboxStore inbox) +internal sealed class JobInputDownloader(NodeHttpClient client, JobRepository inbox) { internal async Task DownloadAsync(RecoverableJob job, CancellationToken cancellationToken) { @@ -42,14 +41,12 @@ internal sealed class JobInputDownloader(NodeConfig config, JobInboxStore inbox) Directory.CreateDirectory(Path.GetDirectoryName(destination)!); var temporaryPath = destination + ".tmp-" + Guid.NewGuid().ToString("N"); - using var client = new HttpClient { BaseAddress = config.ServerUrl }; - client.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", config.NodeToken); - client.DefaultRequestHeaders.Add("X-Node-Id", config.NodeId.ToString()); try { - using var response = await client.GetAsync( - relativeUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + using var response = await client.SendWithRetryAsync( + () => client.CreateRequest(HttpMethod.Get, relativeUri.ToString()), + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); response.EnsureSuccessStatusCode(); if (response.Content.Headers.ContentLength is long contentLength && contentLength != expectedSize) diff --git a/windows-node/Zcbot.WindowsNode/JobOutputUploader.cs b/windows-node/Zcbot.WindowsNode/JobOutputUploader.cs index e3833ef..f4d6e34 100644 --- a/windows-node/Zcbot.WindowsNode/JobOutputUploader.cs +++ b/windows-node/Zcbot.WindowsNode/JobOutputUploader.cs @@ -3,10 +3,11 @@ using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.IO; namespace Zcbot.WindowsNode; -internal sealed class JobOutputUploader(NodeConfig config) +internal sealed class JobOutputUploader(NodeHttpClient client, NodePaths paths) { private const long MaxDiagnosticLogBytes = 1024 * 1024; private static readonly object DiagnosticLogLock = new(); @@ -26,7 +27,7 @@ internal sealed class JobOutputUploader(NodeConfig config) IReadOnlyList previewOutputIds) { var jobDirectory = Path.Combine( - NodePaths.ForCurrentMachine().JobsDirectory, job.JobId.ToString("D")); + paths.JobsDirectory, job.JobId.ToString("D")); var completionPath = Path.Combine(jobDirectory, "upload-complete.json"); if (File.Exists(completionPath)) return; var cloudTerminalPath = Path.Combine(jobDirectory, "cloud-terminal.json"); @@ -36,12 +37,6 @@ internal sealed class JobOutputUploader(NodeConfig config) if (terminal.RootElement.GetProperty("status").GetString() != "succeeded") return; var manifest = terminal.RootElement.GetProperty("artifact_manifest").Clone(); - using var client = new HttpClient { BaseAddress = config.ServerUrl }; - client.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", config.NodeToken); - client.DefaultRequestHeaders.Add("X-Node-Id", config.NodeId.ToString()); - client.DefaultRequestHeaders.Add("X-Lease-Id", job.LeaseId.ToString()); - client.DefaultRequestHeaders.Add("X-Request-Digest", job.RequestDigest); if (recovery) { var replay = await TryCompleteAsync( @@ -68,7 +63,7 @@ internal sealed class JobOutputUploader(NodeConfig config) var outputDirectory = workspaceStateFilename is null ? Path.Combine(jobDirectory, "output") : Path.Combine( - NodePaths.ForCurrentMachine().WorkspacesDirectory, + paths.WorkspacesDirectory, job.Workspace!.WorkspaceId.ToString("D"), "current"); var path = Path.Combine(outputDirectory, filename); @@ -100,9 +95,12 @@ internal sealed class JobOutputUploader(NodeConfig config) content.Headers.ContentLength = expectedSize; content.Headers.Add("X-Content-SHA256", expectedDigest); content.Headers.Add("X-Content-Length", expectedSize.ToString()); - using var response = await client.PutAsync( + using var request = client.CreateRequest( + HttpMethod.Put, $"/v1/software-jobs/{job.JobId:D}/outputs/{Uri.EscapeDataString(localId)}", + job, content); + using var response = await client.SendAsync(request); response.EnsureSuccessStatusCode(); } @@ -116,7 +114,7 @@ internal sealed class JobOutputUploader(NodeConfig config) internal void RecordDeferred(RecoverableJob job, Exception exception) { var jobDirectory = Path.Combine( - NodePaths.ForCurrentMachine().JobsDirectory, job.JobId.ToString("D")); + paths.JobsDirectory, job.JobId.ToString("D")); Log(jobDirectory, "WARN", $"Output upload deferred job={job.JobId:D} " + $"exception={exception.GetType().Name} " @@ -132,7 +130,7 @@ internal sealed class JobOutputUploader(NodeConfig config) throw new InvalidDataException("Export output identities are invalid."); } var jobDirectory = Path.Combine( - NodePaths.ForCurrentMachine().JobsDirectory, job.JobId.ToString("D")); + paths.JobsDirectory, job.JobId.ToString("D")); using var terminal = JsonDocument.Parse( await File.ReadAllBytesAsync(Path.Combine(jobDirectory, "terminal.json"))); var selected = terminal.RootElement.GetProperty("artifact_manifest") @@ -144,14 +142,8 @@ internal sealed class JobOutputUploader(NodeConfig config) { throw new InvalidDataException("Requested local export is unavailable."); } - using var client = new HttpClient { BaseAddress = config.ServerUrl }; - client.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", config.NodeToken); - client.DefaultRequestHeaders.Add("X-Node-Id", config.NodeId.ToString()); - client.DefaultRequestHeaders.Add("X-Lease-Id", job.LeaseId.ToString()); - client.DefaultRequestHeaders.Add("X-Request-Digest", job.RequestDigest); var outputDirectory = Path.Combine( - NodePaths.ForCurrentMachine().WorkspacesDirectory, + paths.WorkspacesDirectory, job.Workspace!.WorkspaceId.ToString("D"), "current"); foreach (var artifact in selected) @@ -183,23 +175,30 @@ internal sealed class JobOutputUploader(NodeConfig config) content.Headers.ContentLength = expectedSize; content.Headers.Add("X-Content-SHA256", expectedDigest); content.Headers.Add("X-Content-Length", expectedSize.ToString()); - using var response = await client.PutAsync( + using var request = client.CreateRequest( + HttpMethod.Put, $"/v1/software-jobs/{job.JobId:D}/outputs/{Uri.EscapeDataString(localId)}", + job, content); + using var response = await client.SendAsync(request); response.EnsureSuccessStatusCode(); } using var completeContent = new StringContent( JsonSerializer.Serialize(new { artifact_manifest = selected }), Encoding.UTF8, "application/json"); - using var completeResponse = await client.PostAsync( - $"/v1/software-jobs/{job.JobId:D}/exports/complete", completeContent); + using var completeRequest = client.CreateRequest( + HttpMethod.Post, + $"/v1/software-jobs/{job.JobId:D}/exports/complete", + job, + completeContent); + using var completeResponse = await client.SendAsync(completeRequest); completeResponse.EnsureSuccessStatusCode(); Log(jobDirectory, "OK", $"On-demand export completed job={job.JobId:D}"); } private static async Task TryCompleteAsync( - HttpClient client, + NodeHttpClient client, string jobDirectory, RecoverableJob job, JsonElement manifest, @@ -217,8 +216,12 @@ internal sealed class JobOutputUploader(NodeConfig config) }), Encoding.UTF8, "application/json"); - using var completeResponse = await client.PostAsync( - $"/v1/software-jobs/{job.JobId:D}/outputs/complete", completeContent); + using var completeRequest = client.CreateRequest( + HttpMethod.Post, + $"/v1/software-jobs/{job.JobId:D}/outputs/complete", + job, + completeContent); + using var completeResponse = await client.SendAsync(completeRequest); if (allowConflict && completeResponse.StatusCode == HttpStatusCode.Conflict) { var body = await completeResponse.Content.ReadAsByteArrayAsync(); diff --git a/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs b/windows-node/Zcbot.WindowsNode/Jobs/JobCoordinator.cs similarity index 51% rename from windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs rename to windows-node/Zcbot.WindowsNode/Jobs/JobCoordinator.cs index 66b6470..7b2aa84 100644 --- a/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs +++ b/windows-node/Zcbot.WindowsNode/Jobs/JobCoordinator.cs @@ -1,91 +1,49 @@ -using System.Net; using System.Net.WebSockets; using System.Reflection; using System.Runtime.InteropServices; -using System.Text; using System.Text.Json; using System.Collections.Concurrent; namespace Zcbot.WindowsNode; -internal sealed class NodeConnectionLoop(NodeConfig config, Action? statusChanged = null) +internal sealed class JobCoordinator : IDisposable { - private readonly SemaphoreSlim sendLock = new(1, 1); - private readonly JobInboxStore jobInbox = new(NodePaths.ForCurrentMachine().JobsDirectory); - private readonly JobInputDownloader inputDownloader = new( - config, new JobInboxStore(NodePaths.ForCurrentMachine().JobsDirectory)); - private readonly NodeAdapterRegistry adapters = NodeAdapterRegistry.CreateDefault( - new JobInboxStore(NodePaths.ForCurrentMachine().JobsDirectory)); - private readonly JobOutputUploader outputUploader = new(config); + private readonly NodeConfig config; + private readonly NodePaths paths; + private readonly JobInboxStore offerInbox; + private readonly JobRepository jobInbox; + private readonly JobRecoveryService jobRecovery; + private readonly NodeHttpClient httpClient; + private readonly JobInputDownloader inputDownloader; + private readonly AdapterCatalog adapters; + private readonly JobOutputUploader outputUploader; private readonly ConcurrentDictionary jobPipelines = new(); private readonly ConcurrentDictionary exportPipelines = new(); private readonly CancellationTokenSource forcedStop = new(); - private readonly WorkspaceStore workspaceStore = new(NodePaths.ForCurrentMachine()); - private static readonly TimeSpan[] Backoff = - [ - TimeSpan.FromSeconds(1), - TimeSpan.FromSeconds(2), - TimeSpan.FromSeconds(5), - TimeSpan.FromSeconds(10), - TimeSpan.FromSeconds(30), - TimeSpan.FromSeconds(60), - ]; + private readonly WorkspaceStore workspaceStore; - internal async Task RunAsync(CancellationToken cancellationToken) + internal JobCoordinator(NodeConfig config, NodePaths paths) { - try - { - var attempt = 0; - while (!cancellationToken.IsCancellationRequested) - { - try - { - Report(NodeState.Connecting, "正在连接 zcbot…"); - await ConnectOnceAsync(cancellationToken); - attempt = 0; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (WebSocketException exception) - { - Report(NodeState.Offline, "连接中断,等待重连"); - Console.Error.WriteLine($"[WARN] WebSocket disconnected: {exception.Message}"); - } - catch (IOException exception) - { - Report(NodeState.Offline, "网络不可用,等待重连"); - Console.Error.WriteLine($"[WARN] Connection I/O failed: {exception.Message}"); - } - catch (JsonException exception) - { - Report(NodeState.Offline, "服务端消息无效,等待重连"); - Console.Error.WriteLine($"[WARN] Invalid server message: {exception.Message}"); - } - catch (NodeEndpointException exception) - { - Report(NodeState.Offline, "WebSocket 握手被拒绝,请检查服务端或反向代理"); - Console.Error.WriteLine($"[WARN] WebSocket handshake rejected: {exception.Message}"); - } + this.config = config; + this.paths = paths; + offerInbox = new JobInboxStore(paths.JobsDirectory); + jobInbox = new JobRepository(paths.JobsDirectory); + jobRecovery = new JobRecoveryService(jobInbox); + httpClient = new NodeHttpClient(config); + inputDownloader = new JobInputDownloader(httpClient, jobInbox); + adapters = AdapterCatalog.CreateDefault(jobInbox, paths); + outputUploader = new JobOutputUploader(httpClient, paths); + workspaceStore = new WorkspaceStore(paths); + } - var baseDelay = Backoff[Math.Min(attempt, Backoff.Length - 1)]; - attempt++; - var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(0, 750)); - var delay = baseDelay + jitter; - Console.WriteLine($"[INFO] Reconnecting in {delay.TotalSeconds:F1}s."); - await Task.Delay(delay, cancellationToken); - } - } - finally + internal async Task WaitForIdleAsync() + { + // 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 || !exportPipelines.IsEmpty) { - // 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 || !exportPipelines.IsEmpty) - { - await Task.WhenAll(jobPipelines.Values.Concat(exportPipelines.Values).ToArray()); - } + await Task.WhenAll(jobPipelines.Values.Concat(exportPipelines.Values).ToArray()); } } @@ -102,93 +60,40 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? } } - private async Task ConnectOnceAsync(CancellationToken cancellationToken) + internal Task ResumeDeferredUploadsAsync( + NodeProtocolClient client, CancellationToken cancellationToken) { - using var socket = new ClientWebSocket(); - socket.Options.SetRequestHeader("Authorization", $"Bearer {config.NodeToken}"); - socket.Options.SetRequestHeader("X-Node-Id", config.NodeId.ToString()); - socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(config.HeartbeatSeconds); - socket.Options.CollectHttpResponseDetails = true; - - var endpoint = NodeUri.WebSocketEndpoint(config.ServerUrl); - Console.WriteLine($"[INFO] Connecting to {endpoint.GetLeftPart(UriPartial.Path)}."); - try + foreach (var decision in jobRecovery.Scan()) { - await socket.ConnectAsync(endpoint, cancellationToken); - } - catch (WebSocketException exception) when ( - socket.HttpStatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) - { - throw new NodeEndpointException( - $"WebSocket handshake returned HTTP {(int?)socket.HttpStatusCode}. " - + "Verify that the server includes the current Windows Node routes and that " - + $"the reverse proxy forwards WebSocket Upgrade for {endpoint.AbsolutePath}. " - + exception.Message); - } - Console.WriteLine("[OK] Node connected."); - Report(NodeState.Online, "已连接"); - - await SendAsync(socket, "hello", RuntimePayload(), cancellationToken); - await ReportRecoverableJobsAsync(socket, cancellationToken); - using var heartbeatStop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var heartbeat = HeartbeatLoopAsync(socket, heartbeatStop.Token); - try - { - await ReceiveLoopAsync(socket, cancellationToken); - } - finally - { - heartbeatStop.Cancel(); - try - { - await heartbeat; - } - catch (OperationCanceledException) when (heartbeatStop.IsCancellationRequested) + if (decision.Action == JobRecoveryAction.ResumeOutputUpload) { + StartJobPipeline(client, decision.Job, recovery: true); } } + return Task.CompletedTask; } - private async Task HeartbeatLoopAsync(ClientWebSocket socket, CancellationToken cancellationToken) + internal async Task ReportRecoverableJobsAsync( + NodeProtocolClient client, CancellationToken cancellationToken) { - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(config.HeartbeatSeconds)); - while (await timer.WaitForNextTickAsync(cancellationToken)) + foreach (var decision in jobRecovery.Scan()) { - await SendAsync(socket, "heartbeat", RuntimePayload(), cancellationToken); - foreach (var job in jobInbox.ReadRecoverableJobs()) + var job = decision.Job; + if (decision.Action == JobRecoveryAction.ReplayTerminal) { - if (job.Terminal is JsonElement terminal - && terminal.GetProperty("status").GetString() == "succeeded" - && !job.UploadComplete - && !job.CloudTerminal) - { - StartJobPipeline(socket, job); - } - } - } - } - - private async Task ReportRecoverableJobsAsync( - ClientWebSocket socket, CancellationToken cancellationToken) - { - foreach (var job in jobInbox.ReadRecoverableJobs()) - { - if (job.Terminal is JsonElement terminal) - { - if (terminal.GetProperty("status").GetString() == "succeeded") - { - if (!job.UploadComplete) - { - if (!job.CloudTerminal) StartJobPipeline(socket, job); - } - } - else - { - await SendAsync(socket, "job_terminal", terminal, cancellationToken); - } + await client.SendAsync("job_terminal", job.Terminal!.Value, cancellationToken); continue; } - await SendAsync(socket, "job_state", new + if (decision.Action == JobRecoveryAction.ResumeOutputUpload) + { + StartJobPipeline(client, job, recovery: true); + continue; + } + if (decision.Action == JobRecoveryAction.None) + { + continue; + } + await client.SendAsync("job_state", new { job_id = job.JobId, lease_id = job.LeaseId, @@ -197,114 +102,74 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? progress = 0, metrics = new { }, }, cancellationToken); - StartJobPipeline(socket, job); + StartJobPipeline(client, job, recovery: true); } } - private async Task ReceiveLoopAsync( - ClientWebSocket socket, CancellationToken cancellationToken) + internal async Task HandleMessageAsync( + NodeProtocolClient client, + NodeProtocolMessage message, + CancellationToken cancellationToken) { - var buffer = new byte[16 * 1024]; - using var message = new MemoryStream(); - while (socket.State == WebSocketState.Open) + var payload = message.Payload; + if (message.Type == "job_offer" && payload.ValueKind != JsonValueKind.Undefined) { - var result = await socket.ReceiveAsync(buffer, cancellationToken); - if (result.MessageType == WebSocketMessageType.Close) - { - if ((int?)result.CloseStatus == 4003) - { - throw new NodeConfigurationException( - "节点身份已被服务端拒绝。请在管理后台确认该 Node ID 未被禁用或删除;" - + "若记录不存在或身份已撤销,请清除本机身份并使用新注册码重新注册。" - + $" 服务端信息:{result.CloseStatusDescription}"); - } - if (result.CloseStatus == WebSocketCloseStatus.PolicyViolation) - { - throw new NodeConfigurationException( - $"节点上报被服务端拒绝:{result.CloseStatusDescription}"); - } - return; - } - if (result.MessageType != WebSocketMessageType.Text) - { - throw new JsonException("Only text WebSocket messages are supported."); - } - message.Write(buffer, 0, result.Count); - if (!result.EndOfMessage) - { - if (message.Length > 1024 * 1024) - { - throw new JsonException("Server message exceeded 1 MiB."); - } - continue; - } - using var document = JsonDocument.Parse(message.ToArray()); - if (document.RootElement.TryGetProperty("type", out var type)) - { - Console.WriteLine($"[INFO] Server message: {type.GetString()}."); - if (type.GetString() == "job_offer" - && document.RootElement.TryGetProperty("payload", out var payload)) - { - var offerResult = jobInbox.Accept(payload, adapters); - await SendAsync( - socket, - offerResult.Accepted ? "job_accept" : "job_reject", - offerResult.Accepted - ? new - { - job_id = offerResult.JobId, - lease_id = offerResult.LeaseId, - request_digest = offerResult.RequestDigest, - } - : new - { - job_id = payload.TryGetProperty("job_id", out var jobId) - ? jobId.GetString() : "", - lease_id = payload.TryGetProperty("lease_id", out var leaseId) - ? leaseId.GetString() : "", - reason = offerResult.Reason, - }, - cancellationToken); - if (offerResult.Accepted) + var offerResult = offerInbox.Accept(payload, adapters); + await client.SendAsync( + offerResult.Accepted ? "job_accept" : "job_reject", + offerResult.Accepted + ? new { - await SendAsync(socket, "job_state", new - { - job_id = offerResult.JobId, - lease_id = offerResult.LeaseId, - request_digest = offerResult.RequestDigest, - stage = "downloading_inputs", - progress = 0, - metrics = new { }, - }, cancellationToken); - var acceptedJob = jobInbox.ReadRecoverableJobs() - .Single(item => item.JobId == offerResult.JobId); - StartJobPipeline(socket, acceptedJob); + job_id = offerResult.JobId, + lease_id = offerResult.LeaseId, + request_digest = offerResult.RequestDigest, } - } - else if (type.GetString() == "job_cancel" - && document.RootElement.TryGetProperty("payload", out var cancelPayload) - && TryCancelJob(cancelPayload, out var cancelledJob)) - { - adapters.Find(cancelledJob.Capability)?.Cancel(cancelledJob.JobId); - jobInbox.WriteTerminal( - cancelledJob, "cancelled", "USER_CANCELLED", "Cancelled by user."); - await SendAsync(socket, "job_terminal", new + : new { - job_id = cancelledJob.JobId, - lease_id = cancelledJob.LeaseId, - request_digest = cancelledJob.RequestDigest, - status = "cancelled", - error = new { code = "USER_CANCELLED", detail = "Cancelled by user." }, - artifact_manifest = Array.Empty(), - }, cancellationToken); - } - else if (type.GetString() == "job_export" - && document.RootElement.TryGetProperty("payload", out var exportPayload)) + job_id = payload.TryGetProperty("job_id", out var jobId) + ? jobId.GetString() : "", + lease_id = payload.TryGetProperty("lease_id", out var leaseId) + ? leaseId.GetString() : "", + reason = offerResult.Reason, + }, + cancellationToken); + if (offerResult.Accepted) + { + await client.SendAsync("job_state", new { - StartExport(exportPayload); - } + job_id = offerResult.JobId, + lease_id = offerResult.LeaseId, + request_digest = offerResult.RequestDigest, + stage = "downloading_inputs", + progress = 0, + metrics = new { }, + }, cancellationToken); + var acceptedJob = jobInbox.ReadRecoverableJobs() + .Single(item => item.JobId == offerResult.JobId); + StartJobPipeline(client, acceptedJob); } - message.SetLength(0); + } + else if (message.Type == "job_cancel" + && payload.ValueKind != JsonValueKind.Undefined + && TryCancelJob(payload, out var cancelledJob)) + { + adapters.Find(cancelledJob.Capability)?.Cancel(cancelledJob.JobId); + jobInbox.WriteTerminal( + cancelledJob, "cancelled", "USER_CANCELLED", "Cancelled by user."); + await client.SendAsync("job_terminal", new + { + job_id = cancelledJob.JobId, + lease_id = cancelledJob.LeaseId, + request_digest = cancelledJob.RequestDigest, + status = "cancelled", + error = new { code = "USER_CANCELLED", detail = "Cancelled by user." }, + artifact_manifest = Array.Empty(), + }, cancellationToken); + } + else if (message.Type == "job_export" + && payload.ValueKind != JsonValueKind.Undefined) + { + StartExport(payload); } } @@ -380,22 +245,28 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? return job is not null; } - private void StartJobPipeline(ClientWebSocket socket, RecoverableJob job) + private void StartJobPipeline( + NodeProtocolClient socket, + RecoverableJob job, + bool recovery = false) { var completion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); if (jobPipelines.TryAdd(job.JobId, completion.Task)) { - _ = RunJobPipelineAndReleaseAsync(socket, job, completion); + _ = RunJobPipelineAndReleaseAsync(socket, job, recovery, completion); } } private async Task RunJobPipelineAndReleaseAsync( - ClientWebSocket socket, RecoverableJob job, TaskCompletionSource completion) + NodeProtocolClient socket, + RecoverableJob job, + bool recovery, + TaskCompletionSource completion) { try { - await RunJobPipelineAsync(socket, job); + await RunJobPipelineAsync(socket, job, recovery); } finally { @@ -404,7 +275,10 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? } } - private async Task RunJobPipelineAsync(ClientWebSocket socket, RecoverableJob job) + private async Task RunJobPipelineAsync( + NodeProtocolClient socket, + RecoverableJob job, + bool recovery) { var adapter = adapters.Find(job.Capability) ?? throw new InvalidDataException($"No local adapter for {job.Capability}."); @@ -413,7 +287,12 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? { if (job.Terminal is null) { - jobInbox.WriteState(job, "downloading_inputs", 0, "正在下载并校验输入文件"); + jobInbox.WriteState( + job, + "downloading_inputs", + 0, + "正在下载并校验输入文件", + recovery ? JobTransitionMode.Recovery : JobTransitionMode.Normal); await inputDownloader.DownloadAsync(job, forcedStop.Token); var afterDownload = jobInbox.ReadRecoverableJobs() .Single(item => item.JobId == job.JobId); @@ -478,10 +357,14 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? adapter.Capability, adapter.WorkspaceStateFilename); } - jobInbox.WriteState(refreshed, "uploading_outputs", 90, + jobInbox.WriteState( + refreshed, + "uploading_outputs", + 90, adapter.WorkspaceStateFilename is null ? "软件执行完成,正在上传结果" - : "软件执行完成,正在上传预览"); + : "软件执行完成,正在上传预览", + recovery ? JobTransitionMode.Recovery : JobTransitionMode.Normal); await TrySendAsync(socket, "job_state", new { job_id = job.JobId, @@ -562,7 +445,7 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? jobInbox.WriteState(job, "cancelled", 0, "本机管理员退出节点并取消了任务"); } - private async Task TrySendTerminalAsync(ClientWebSocket socket, RecoverableJob job) + private async Task TrySendTerminalAsync(NodeProtocolClient socket, RecoverableJob job) { var terminal = jobInbox.ReadRecoverableJobs() .Single(item => item.JobId == job.JobId).Terminal; @@ -572,15 +455,15 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? } } - private async Task TrySendAsync(ClientWebSocket socket, string type, object payload) + private async Task TrySendAsync(NodeProtocolClient socket, string type, object payload) { - if (socket.State != WebSocketState.Open) + if (!socket.IsOpen) { return; } try { - await SendAsync(socket, type, payload, CancellationToken.None); + await socket.SendAsync(type, payload, CancellationToken.None); } catch (Exception exception) when ( exception is WebSocketException or IOException or ObjectDisposedException) @@ -589,35 +472,9 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? } } - private void Report(NodeState state, string message) => - statusChanged?.Invoke(NodeStatus.Create(state, message)); - - private async Task SendAsync( - ClientWebSocket socket, string type, object payload, CancellationToken cancellationToken) + internal object RuntimePayload() { - var envelope = JsonSerializer.SerializeToUtf8Bytes(new - { - protocol_version = 1, - message_id = Guid.NewGuid(), - type, - sent_at = DateTimeOffset.UtcNow, - payload, - }); - await sendLock.WaitAsync(cancellationToken); - try - { - await socket.SendAsync( - envelope, WebSocketMessageType.Text, endOfMessage: true, cancellationToken); - } - finally - { - sendLock.Release(); - } - } - - private object RuntimePayload() - { - var root = Path.GetPathRoot(NodePaths.ForCurrentMachine().RootDirectory) + var root = Path.GetPathRoot(paths.RootDirectory) ?? throw new NodeConfigurationException("Data root has no drive."); var capabilityRuntime = adapters.All .ToDictionary( @@ -671,4 +528,10 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? }, }; } + + public void Dispose() + { + forcedStop.Dispose(); + httpClient.Dispose(); + } } diff --git a/windows-node/Zcbot.WindowsNode/Jobs/JobRecoveryService.cs b/windows-node/Zcbot.WindowsNode/Jobs/JobRecoveryService.cs new file mode 100644 index 0000000..eef08b6 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Jobs/JobRecoveryService.cs @@ -0,0 +1,38 @@ +using System.Text.Json; + +namespace Zcbot.WindowsNode; + +internal enum JobRecoveryAction +{ + ResumeExecution, + ResumeOutputUpload, + ReplayTerminal, + None, +} + +internal sealed record JobRecoveryDecision( + RecoverableJob Job, + JobRecoveryAction Action); + +internal sealed class JobRecoveryService(JobRepository repository) +{ + internal IReadOnlyList Scan() => + repository.ReadRecoverableJobs() + .Select(job => new JobRecoveryDecision(job, Decide(job))) + .ToArray(); + + internal static JobRecoveryAction Decide(RecoverableJob job) + { + if (job.Terminal is not JsonElement terminal) + { + return JobRecoveryAction.ResumeExecution; + } + if (terminal.GetProperty("status").GetString() != JobStateMachine.Succeeded) + { + return JobRecoveryAction.ReplayTerminal; + } + return job.UploadComplete || job.CloudTerminal + ? JobRecoveryAction.None + : JobRecoveryAction.ResumeOutputUpload; + } +} diff --git a/windows-node/Zcbot.WindowsNode/Jobs/JobRepository.cs b/windows-node/Zcbot.WindowsNode/Jobs/JobRepository.cs new file mode 100644 index 0000000..dabbca8 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Jobs/JobRepository.cs @@ -0,0 +1,161 @@ +using System.Text.Json; + +namespace Zcbot.WindowsNode; + +internal sealed class JobRepository(string jobsDirectory) +{ + private string JobsDirectory => jobsDirectory; + + internal string JobDirectory(Guid jobId) => + PathGuard.CombineUnderRoot(JobsDirectory, jobId.ToString("D")); + + internal bool HasPendingJobs => Directory.Exists(JobsDirectory) + && ReadRecoverableJobs().Any(item => item.Terminal is null); + + internal IReadOnlyList ReadRecoverableJobs() + { + if (!Directory.Exists(JobsDirectory)) + { + return []; + } + var jobs = new List(); + foreach (var requestPath in Directory.EnumerateFiles( + JobsDirectory, "request.json", SearchOption.AllDirectories)) + { + try + { + using var request = JsonDocument.Parse(File.ReadAllBytes(requestPath)); + var root = request.RootElement; + if (!JobInboxStore.TryReadGuid(root, "job_id", out var jobId) + || !JobInboxStore.TryReadGuid(root, "lease_id", out var leaseId) + || !root.TryGetProperty("request_digest", out var digestValue) + || digestValue.GetString() is not { Length: 64 } requestDigest + || !root.TryGetProperty("capability", out var capabilityValue) + || capabilityValue.GetString() is not { Length: > 0 } capability + || !root.TryGetProperty("workspace", out var workspaceValue) + || !JobInboxStore.TryReadWorkspace(workspaceValue, out var workspace)) + { + continue; + } + var jobDirectory = Directory.GetParent( + Directory.GetParent(requestPath)!.FullName)!.FullName; + jobs.Add(new RecoverableJob( + jobId, + leaseId, + requestDigest, + capability, + workspace, + root.TryGetProperty("input_transfers", out var transfers) + ? transfers.Clone() : null, + JobInboxStore.ReadTerminal(Path.Combine(jobDirectory, "terminal.json")), + File.Exists(Path.Combine(jobDirectory, "upload-complete.json")), + File.Exists(Path.Combine(jobDirectory, "cloud-terminal.json")))); + } + catch (Exception exception) when ( + exception is JsonException or IOException or UnauthorizedAccessException) + { + } + } + return jobs; + } + + internal void WriteState( + RecoverableJob job, + string stage, + int progress, + string detail, + JobTransitionMode mode = JobTransitionMode.Normal) + { + var path = Path.Combine(JobDirectory(job.JobId), "state.json"); + var current = JobInboxStore.ReadState(path)?.Stage ?? JobStateMachine.Accepted; + JobStateMachine.EnsureTransition(current, stage, mode); + var content = JsonSerializer.SerializeToUtf8Bytes(new + { + stage, + progress = Math.Clamp(progress, 0, 100), + detail = detail[..Math.Min(detail.Length, 500)], + updated_at = DateTimeOffset.UtcNow, + }, JobInboxStore.JsonOptions); + try + { + AtomicFile.Write(path, content, overwrite: true); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"[WARN] Local job state update failed: {exception.Message}"); + } + } + + internal void EnsureAcceptedState(Guid jobId) + { + var path = Path.Combine(JobDirectory(jobId), "state.json"); + if (File.Exists(path)) + { + return; + } + var content = JsonSerializer.SerializeToUtf8Bytes(new + { + stage = JobStateMachine.Accepted, + progress = 0, + detail = "任务已由本机接收", + updated_at = DateTimeOffset.UtcNow, + }, JobInboxStore.JsonOptions); + try + { + AtomicFile.Write(path, content, overwrite: false); + } + catch (IOException) when (File.Exists(path)) + { + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"[WARN] Initial local job state failed: {exception.Message}"); + } + } + + internal IReadOnlyList InputTransfers(RecoverableJob job) + { + if (job.InputTransfers is not JsonElement transfers + || !JobInboxStore.IsValidInputTransfers(transfers, job.JobId)) + { + throw new InvalidDataException("Stored input transfers are invalid."); + } + return transfers.EnumerateArray().Select(item => item.Clone()).ToArray(); + } + + internal string InputPath(RecoverableJob job, JsonElement transfer) + { + var key = transfer.GetProperty("key").GetString()!; + var filename = transfer.GetProperty("filename").GetString()!; + return PathGuard.CombineUnderRoot( + JobDirectory(job.JobId), "input", key, filename); + } + + internal void WriteTerminal( + RecoverableJob job, + string status, + string code, + string detail, + bool overwrite = false) + { + JobStateMachine.EnsureTerminalStatus(status); + var path = Path.Combine(JobDirectory(job.JobId), "terminal.json"); + if (File.Exists(path) && !overwrite) + { + return; + } + var content = JsonSerializer.SerializeToUtf8Bytes(new + { + job_id = job.JobId, + lease_id = job.LeaseId, + request_digest = job.RequestDigest, + status, + error = new { code, detail }, + artifact_manifest = Array.Empty(), + terminal_at = DateTimeOffset.UtcNow, + }, JobInboxStore.JsonOptions); + AtomicFile.Write(path, content, overwrite); + } +} diff --git a/windows-node/Zcbot.WindowsNode/Jobs/JobStateMachine.cs b/windows-node/Zcbot.WindowsNode/Jobs/JobStateMachine.cs new file mode 100644 index 0000000..bed077d --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Jobs/JobStateMachine.cs @@ -0,0 +1,77 @@ +namespace Zcbot.WindowsNode; + +internal enum JobTransitionMode +{ + Normal, + Recovery, +} + +internal static class JobStateMachine +{ + internal const string Accepted = "accepted"; + internal const string DownloadingInputs = "downloading_inputs"; + internal const string ReadyToRun = "ready_to_run"; + internal const string SoftwareRunning = "software_running"; + internal const string UploadingOutputs = "uploading_outputs"; + internal const string Succeeded = "succeeded"; + internal const string Failed = "failed"; + internal const string Cancelled = "cancelled"; + + private static readonly IReadOnlyDictionary> Transitions = + new Dictionary>(StringComparer.Ordinal) + { + [Accepted] = Set(DownloadingInputs, Failed, Cancelled), + [DownloadingInputs] = Set(DownloadingInputs, ReadyToRun, Failed, Cancelled), + [ReadyToRun] = Set(SoftwareRunning, Failed, Cancelled), + [SoftwareRunning] = Set(UploadingOutputs, Failed, Cancelled), + [UploadingOutputs] = Set(UploadingOutputs, Succeeded, Failed, Cancelled), + [Succeeded] = Set(), + [Failed] = Set(), + [Cancelled] = Set(), + }; + + internal static IReadOnlySet Stages { get; } = + Transitions.Keys.ToHashSet(StringComparer.Ordinal); + + internal static IReadOnlySet TerminalStages { get; } = + Set(Succeeded, Failed, Cancelled); + + internal static bool IsTerminal(string stage) => TerminalStages.Contains(stage); + + internal static void EnsureTransition( + string current, + string next, + JobTransitionMode mode = JobTransitionMode.Normal) + { + if (!Transitions.ContainsKey(current) || !Transitions.ContainsKey(next)) + { + throw new InvalidDataException("Unsupported local job stage."); + } + if (current == next && !IsTerminal(current)) + { + return; + } + if (Transitions[current].Contains(next)) + { + return; + } + if (mode == JobTransitionMode.Recovery + && !IsTerminal(current) + && next is DownloadingInputs or UploadingOutputs) + { + return; + } + throw new InvalidDataException($"Invalid local job transition: {current} -> {next}."); + } + + internal static void EnsureTerminalStatus(string status) + { + if (!TerminalStages.Contains(status)) + { + throw new InvalidDataException("Unsupported local terminal status."); + } + } + + private static IReadOnlySet Set(params string[] values) => + values.ToHashSet(StringComparer.Ordinal); +} diff --git a/windows-node/Zcbot.WindowsNode/NodeAdapters.cs b/windows-node/Zcbot.WindowsNode/NodeAdapters.cs index 0beebf6..ea930a6 100644 --- a/windows-node/Zcbot.WindowsNode/NodeAdapters.cs +++ b/windows-node/Zcbot.WindowsNode/NodeAdapters.cs @@ -234,14 +234,20 @@ internal sealed class ProcessNodeAdapter : INodeAdapter OutputFormat = OutputFormat.Flag, RequireFormatValidation = true, }; - private readonly AdapterProcessRunner runner; - private AdapterRuntimeStatus? cachedRuntime; - private DateTimeOffset runtimeCheckedAt; + private readonly AdapterExecutionService runner; + private readonly SoftwareProbeService probe; - internal ProcessNodeAdapter(AdapterDescriptor descriptor, JobInboxStore inbox) + internal ProcessNodeAdapter( + AdapterDescriptor descriptor, + JobRepository inbox, + NodePaths paths, + JobExecutionGate executionGate, + ProcessSupervisor processSupervisor) { Descriptor = descriptor; - runner = new AdapterProcessRunner(descriptor, inbox); + runner = new AdapterExecutionService( + descriptor, inbox, paths, executionGate, processSupervisor); + probe = new SoftwareProbeService(runner); } internal AdapterDescriptor Descriptor { get; } @@ -262,21 +268,11 @@ internal sealed class ProcessNodeAdapter : INodeAdapter public string? WorkspaceStateFilename => Descriptor.Contract.WorkspaceStateFilename; public IReadOnlyList PreviewOutputIds => Descriptor.Contract.PreviewOutputIds; - public AdapterRuntimeStatus DetectRuntime() - { - if (cachedRuntime is null - || DateTimeOffset.UtcNow - runtimeCheckedAt > TimeSpan.FromSeconds(30)) - { - cachedRuntime = runner.Probe(); - runtimeCheckedAt = DateTimeOffset.UtcNow; - } - return cachedRuntime; - } + public AdapterRuntimeStatus DetectRuntime() => probe.Detect(); public void InvalidateRuntime() { - cachedRuntime = null; - runtimeCheckedAt = default; + probe.Invalidate(); } public bool ValidateRequest(JsonElement request) => @@ -293,49 +289,3 @@ internal sealed class ProcessNodeAdapter : INodeAdapter public Task RunAsync(RecoverableJob job) => runner.RunAsync(job); public void Cancel(Guid jobId) => runner.Cancel(jobId); } - -internal sealed class NodeAdapterRegistry -{ - private readonly IReadOnlyDictionary adapters; - - internal NodeAdapterRegistry(IEnumerable values) - { - adapters = values.ToDictionary(item => item.Capability, StringComparer.Ordinal); - } - - internal static string AdapterRoot => - Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "adapters")); - - internal static NodeAdapterRegistry CreateDefault(JobInboxStore inbox) => - new(DiscoverDescriptors().Select(item => new ProcessNodeAdapter(item, inbox))); - - internal static IReadOnlyList InstalledContracts => - DiscoverDescriptors().Select(item => item.Contract).ToArray(); - - internal static IReadOnlyList InstalledCapabilities => - InstalledContracts.Select(item => item.Capability).ToArray(); - - internal IReadOnlyCollection All => adapters.Values.ToArray(); - - internal INodeAdapter? Find(string capability) => - adapters.TryGetValue(capability, out var adapter) ? adapter : null; - - internal void InvalidateRuntime(string runtimeId) - { - foreach (var adapter in adapters.Values.Where( - item => runtimeId.Equals(item.RuntimeId, StringComparison.Ordinal))) - { - adapter.InvalidateRuntime(); - } - } - - private static IReadOnlyList DiscoverDescriptors() - { - if (!Directory.Exists(AdapterRoot)) return []; - return Directory.EnumerateDirectories(AdapterRoot) - .Where(path => File.Exists(Path.Combine(path, "adapter.json"))) - .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) - .Select(AdapterDescriptor.Load) - .ToArray(); - } -} diff --git a/windows-node/Zcbot.WindowsNode/NodeConfigStore.cs b/windows-node/Zcbot.WindowsNode/NodeConfigStore.cs index 415544c..4059ce7 100644 --- a/windows-node/Zcbot.WindowsNode/NodeConfigStore.cs +++ b/windows-node/Zcbot.WindowsNode/NodeConfigStore.cs @@ -6,15 +6,24 @@ using System.Text.Json; namespace Zcbot.WindowsNode; -internal sealed class NodeConfigStore(NodePaths paths) +internal interface INodeConfigStore +{ + bool Exists { get; } + string ConfigPath { get; } + void Save(NodeConfig config); + NodeConfig Load(); + void DeleteLocalIdentity(); +} + +internal sealed class NodeConfigStore(NodePaths paths) : INodeConfigStore { private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("zcbot.windows-node.v1"); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; - internal bool Exists => File.Exists(paths.ConfigPath); - internal string ConfigPath => paths.ConfigPath; + public bool Exists => File.Exists(paths.ConfigPath); + public string ConfigPath => paths.ConfigPath; - internal void Save(NodeConfig config) + public void Save(NodeConfig config) { Directory.CreateDirectory(paths.RootDirectory); RestrictDirectory(paths.RootDirectory); @@ -36,7 +45,7 @@ internal sealed class NodeConfigStore(NodePaths paths) RestrictFile(paths.ConfigPath); } - internal NodeConfig Load() + public NodeConfig Load() { if (!Exists) { @@ -84,7 +93,7 @@ internal sealed class NodeConfigStore(NodePaths paths) } } - internal void DeleteLocalIdentity() + public void DeleteLocalIdentity() { if (File.Exists(paths.ConfigPath)) { diff --git a/windows-node/Zcbot.WindowsNode/NodeDataRoot.cs b/windows-node/Zcbot.WindowsNode/NodeDataRoot.cs index 23aa202..3d3de1c 100644 --- a/windows-node/Zcbot.WindowsNode/NodeDataRoot.cs +++ b/windows-node/Zcbot.WindowsNode/NodeDataRoot.cs @@ -1,4 +1,5 @@ using Microsoft.Win32; +using System.IO; using System.Security; using System.Security.Cryptography; diff --git a/windows-node/Zcbot.WindowsNode/Persistence/AtomicFile.cs b/windows-node/Zcbot.WindowsNode/Persistence/AtomicFile.cs new file mode 100644 index 0000000..e596fc2 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Persistence/AtomicFile.cs @@ -0,0 +1,32 @@ +namespace Zcbot.WindowsNode; + +internal static class AtomicFile +{ + internal static void Write(string path, byte[] content, bool overwrite) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var temporaryPath = path + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + FileOptions.WriteThrough)) + { + stream.Write(content); + stream.Flush(flushToDisk: true); + } + File.Move(temporaryPath, path, overwrite); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } +} diff --git a/windows-node/Zcbot.WindowsNode/Persistence/PathGuard.cs b/windows-node/Zcbot.WindowsNode/Persistence/PathGuard.cs new file mode 100644 index 0000000..631523d --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Persistence/PathGuard.cs @@ -0,0 +1,20 @@ +namespace Zcbot.WindowsNode; + +internal static class PathGuard +{ + internal static string CombineUnderRoot(string root, params string[] segments) + { + var normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + if (segments.Any(Path.IsPathFullyQualified)) + { + throw new InvalidDataException("A persisted path segment must be relative."); + } + var candidate = Path.GetFullPath(Path.Combine([normalizedRoot, .. segments])); + var prefix = normalizedRoot + Path.DirectorySeparatorChar; + if (!candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException("A persisted path escaped its expected root."); + } + return candidate; + } +} diff --git a/windows-node/Zcbot.WindowsNode/Presentation/App.cs b/windows-node/Zcbot.WindowsNode/Presentation/App.cs new file mode 100644 index 0000000..a061778 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Presentation/App.cs @@ -0,0 +1,47 @@ +using System.Windows; + +namespace Zcbot.WindowsNode; + +internal sealed class App : System.Windows.Application +{ + private readonly NodeApplicationController controller; + private TrayHost? trayHost; + + internal App(NodeApplicationController controller) + { + this.controller = controller; + ShutdownMode = ShutdownMode.OnExplicitShutdown; + Resources.MergedDictionaries.Add(new ResourceDictionary + { + Source = new Uri( + "pack://application:,,,/Zcbot.WindowsNode;component/Presentation/Themes/LightTheme.xaml", + UriKind.Absolute), + }); + } + + protected override void OnStartup(StartupEventArgs eventArgs) + { + base.OnStartup(eventArgs); + var viewModel = new MainWindowViewModel( + new StartupRegistrationService(), + new DiagnosticService(controller.Paths), + new SoftwarePageViewModel( + new SoftwareManagementService(controller.Paths)), + new JobPageViewModel( + new JobMonitorService(controller.Paths.JobsDirectory)), + new AnsysAcceptanceViewModel( + new AnsysAcceptanceService(controller.Paths)), + new DataRootPageViewModel( + new DataRootManagementService(controller.Paths))); + var window = new MainWindow(viewModel); + MainWindow = window; + trayHost = new TrayHost(controller, window, Shutdown); + trayHost.Start(); + } + + protected override void OnExit(ExitEventArgs eventArgs) + { + trayHost?.Dispose(); + base.OnExit(eventArgs); + } +} diff --git a/windows-node/Zcbot.WindowsNode/Presentation/Infrastructure/AsyncCommand.cs b/windows-node/Zcbot.WindowsNode/Presentation/Infrastructure/AsyncCommand.cs new file mode 100644 index 0000000..a2cce55 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Presentation/Infrastructure/AsyncCommand.cs @@ -0,0 +1,54 @@ +using System.Windows.Input; + +namespace Zcbot.WindowsNode; + +internal sealed class AsyncCommand : ObservableObject, ICommand +{ + private readonly Func execute; + private readonly Func? canExecute; + private bool isRunning; + + internal AsyncCommand(Func execute, Func? canExecute = null) + { + this.execute = execute; + this.canExecute = canExecute; + } + + public event EventHandler? CanExecuteChanged; + + internal event Action? Failed; + + internal bool IsRunning + { + get => isRunning; + private set + { + if (SetProperty(ref isRunning, value)) + { + CanExecuteChanged?.Invoke(this, EventArgs.Empty); + } + } + } + + public bool CanExecute(object? parameter) => !IsRunning && (canExecute?.Invoke() ?? true); + + public async void Execute(object? parameter) + { + if (!CanExecute(parameter)) return; + IsRunning = true; + try + { + await execute(); + } + catch (Exception exception) + { + Failed?.Invoke(exception); + } + finally + { + IsRunning = false; + } + } + + internal void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/windows-node/Zcbot.WindowsNode/Presentation/Infrastructure/ObservableObject.cs b/windows-node/Zcbot.WindowsNode/Presentation/Infrastructure/ObservableObject.cs new file mode 100644 index 0000000..7ca9fd1 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Presentation/Infrastructure/ObservableObject.cs @@ -0,0 +1,26 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace Zcbot.WindowsNode; + +internal abstract class ObservableObject : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + protected bool SetProperty( + ref T field, + T value, + [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return false; + } + field = value; + OnPropertyChanged(propertyName); + return true; + } + + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} diff --git a/windows-node/Zcbot.WindowsNode/Presentation/Infrastructure/RelayCommand.cs b/windows-node/Zcbot.WindowsNode/Presentation/Infrastructure/RelayCommand.cs new file mode 100644 index 0000000..0f204e0 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Presentation/Infrastructure/RelayCommand.cs @@ -0,0 +1,14 @@ +using System.Windows.Input; + +namespace Zcbot.WindowsNode; + +internal sealed class RelayCommand(Action execute, Func? canExecute = null) : ICommand +{ + public event EventHandler? CanExecuteChanged; + + public bool CanExecute(object? parameter) => canExecute?.Invoke() ?? true; + + public void Execute(object? parameter) => execute(); + + internal void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/windows-node/Zcbot.WindowsNode/Presentation/MainWindow.xaml b/windows-node/Zcbot.WindowsNode/Presentation/MainWindow.xaml new file mode 100644 index 0000000..172c775 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Presentation/MainWindow.xaml @@ -0,0 +1,453 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +