feat(windows-node): complete WPF host migration

This commit is contained in:
caoqianming 2026-08-31 17:22:11 +08:00
parent 0d5cbb8407
commit 38deea7d1d
88 changed files with 8386 additions and 2913 deletions

View File

@ -10,6 +10,8 @@
- DeepSeek Flash 的思考过程恢复实时显示,不再等到正文开始后才集中出现。
- Windows 专业软件节点启用完整的新管理界面节点状态、首次注册、软件环境、本机任务、ANSYS 验收和数据目录迁移均可直接管理;应用图标、导航、状态提示和窄窗口布局也更清晰,关闭窗口后仍会继续在托盘运行。
- 国内模型成本统计跟随最新公开价格更新DeepSeek 会按实际峰谷时段和缓存命中量计算,历史调价后的记录也可审计矫正。
- GLM-5.3 Flash 替换旧版 GLM 并向默认档位开放;上传图片会直接交给主模型理解,长工具任务可延续既有分析状态。

View File

@ -478,6 +478,8 @@ Core 在 Job offer 中附带由 capability 合同生成的 `request_summary`
扩展现有 capability 的 feature 时修改共享契约、对应 Worker 和测试,不修改 Core 调度、Job 生命周期或 Node EXE增加新专业软件时新增契约与一个 adapter 目录。Cloud 会在下一次契约查询时获得新校验和工具 schemaNode 在启动时从本地目录获得能力、版本和入口,并在首次连接及每次心跳中上报全部已发现 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 表达。

View File

@ -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 重构阶段 07;未发版)
---
@ -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 重构阶段 06 第二切片**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/依赖变化,未调用真实模型或连接生产数据库。

2
RUN.md
View File

@ -1178,7 +1178,7 @@ Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打
注册配置写入 `<data_root>\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% 重传;这些本地结果不会反向覆盖云端终态。

View File

@ -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("<ToggleButton"), 3)
self.assertIn('IsChecked="{Binding IsOverviewPage, Mode=OneWay}"', xaml)
self.assertIn('IsChecked="{Binding IsSoftwarePage, Mode=OneWay}"', xaml)
self.assertIn('IsChecked="{Binding IsSettingsPage, Mode=OneWay}"', xaml)
self.assertIn('Binding="{Binding StatusTone}"', xaml)
self.assertIn("SoftwarePage.IsBusy", xaml)
self.assertGreaterEqual(xaml.count("<WrapPanel"), 3)
self.assertIn("HasOperationMessage", xaml)
for token in (
"SidebarSelectedBrush",
"SuccessSurfaceBrush",
"WarningSurfaceBrush",
"ErrorSurfaceBrush",
"CardBorderStyle",
"SectionTitleStyle",
):
self.assertIn(token, theme)
self.assertIn('<ControlTemplate TargetType="Button">', theme)
self.assertIn('<ControlTemplate TargetType="ToggleButton">', 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("<PasswordBox", xaml)
self.assertNotIn("NodeToken", xaml + view_model + diagnostic_service)
self.assertIn("Clipboard.SetText(text)", tray)
diagnostics = diagnostic_service.split("public string Build", 1)[1]
self.assertNotIn("NodeToken", diagnostics)
self.assertIn("内容不包含 Node Token", form)
self.assertIn("清除本机身份", form)
self.assertIn("管理后台删除或禁用云端旧节点", form)
self.assertIn("内容不包含 Node Token", view_model)
self.assertIn("清除本机身份", xaml)
self.assertIn("管理后台删除或禁用云端旧节点", tray)
def test_ansys_acceptance_ui_runs_only_fixed_bundled_suite_before_gate(self) -> 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("<ScrollViewer", xaml)
self.assertIn('Content="注册并连接"', xaml)
self.assertIn('Content="节点概览"', xaml)
self.assertIn('Content="专业软件"', xaml)
self.assertIn('Content="本机任务"', xaml)
self.assertIn('Content="运行设置"', xaml)
self.assertIn("<DataGrid", xaml)
self.assertFalse((PROJECT / "ConfigurationForm.cs").exists())
self.assertFalse((PROJECT / "TrayApplicationContext.cs").exists())
tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8")
self.assertIn("form.ReconnectRequested += RestartConnection", tray)
tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text(
encoding="utf-8"
)
self.assertNotIn("legacyForm", tray)
self.assertIn("statusItem.Text = CompactMenuText(status.Message, 24)", tray)
self.assertIn('singleLine[..(length - 1)] + ""', tray)
def test_tray_exit_waits_for_or_cancels_active_jobs(self) -> 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<Guid, Task> 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__":

View File

@ -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 脚本或独立 EXEmanifest 独立声明每种软件的任务超时及可选 Workspace 状态。当前安装包带 Origin、ANSYS 和 `blender.scene.author@v3` Worker但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录Origin OPJU 与 Blender `.blend` 工程保存在各自的 `workspaces/<workspace-id>/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 脚本或独立 EXEmanifest 独立声明每种软件的任务超时及可选 Workspace 状态。当前安装包带 Origin、ANSYS 和 `blender.scene.author@v3` Worker但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录Origin OPJU 与 Blender `.blend` 工程保存在各自的 `workspaces/<workspace-id>/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 条已接收任务及执行详情。每个任务的输出上传诊断日志保存在 `<data_root>\jobs\<job-id>\logs\node-output-upload.log`,记录阶段、产物文件名、重试次数和 Windows `HRESULT`,不记录 Node Token 或认证请求头;单文件达到 1 MiB 后轮转一份 `.1`。无界面运行使用:
直接双击 EXE 默认启动托盘 UI红点表示未注册或身份失效黄点表示正在连接绿点表示在线。双击托盘图标打开 WPF 主窗口;“本机任务”页每秒刷新已派发到本机的最近记录,选择一行可查看阶段、能力、更新时间和完整 Job IDANSYS 验收位于“专业软件”页,数据目录打开与迁移位于“运行设置”页。每个任务的输出上传诊断日志保存在 `<data_root>\jobs\<job-id>\logs\node-output-upload.log`,记录阶段、产物文件名、重试次数和 Windows `HRESULT`,不记录 Node Token 或认证请求头;单文件达到 1 MiB 后轮转一份 `.1`。无界面运行使用:
```powershell
Zcbot.WindowsNode.exe run --headless

View File

@ -0,0 +1,774 @@
# Windows Node Host 与 WPF 重构方案
> 状态:阶段 07 已落地,进入阶段 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 RootGUI 与 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<NodeShutdownResult> StopAsync(NodeShutdownMode mode, CancellationToken cancellationToken);
NodeHostSnapshot Current { get; }
event EventHandler<NodeHostSnapshot>? 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、专项测试、打包和启动烟测全部通过。
### 阶段 1Composition Root 与应用控制器
- 引入唯一 `NodeCompositionRoot`
- 从托盘类提取注册、连接、重连、迁移和退出编排。
- GUI 与 headless 共用 Host 构造路径。
- WinForms 界面保持不变。
完成门槛:现有界面和 CLI 行为完全等价。
### 阶段 2Job 持久化与状态机
- 提取 `JobRepository`、`JobStateMachine` 和 `JobRecoveryService`
- 集中原子文件和路径约束。
- 保持现有落盘格式,验证新版能恢复旧 Job。
- 增加状态机和恢复测试。
完成门槛:旧 Job 样本可恢复,终态与 Workspace 不回退。
### 阶段 3会话、协议与传输
- 拆分 `NodeSession`、`NodeProtocolCodec` 和 `NodeProtocolClient`
- 引入共享 `NodeHttpClient`
- 统一错误分类、认证注入和安全重试。
- 由 `JobCoordinator` 接管消息业务编排。
完成门槛假服务端重连、offer、cancel、上传重试和身份拒绝测试通过。
### 阶段 4Adapter、进程与 runtime
- 提取 `AdapterCatalog`、`AdapterExecutionService` 和 `ProcessSupervisor`
- 拆分软件位置、probe 和 runtime 安装。
- 统一活动任务执行门和取消收尾。
- 不改 Adapter 文件或合同。
完成门槛Origin、ANSYS、Blender 现有 probe、执行、取消和打包测试通过。
### 阶段 5WPF 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-Agentlease/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 不直接依赖 Controller69 项 .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不访问注册表也不创建 Worker117 项 .NET 行为测试、28 项 Windows Node 源码专项、格式与 diff 检查通过。下一步进入阶段 8 发布准备与目标机 DPI/GUI 烟测。

View File

@ -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<OperationCanceledException>(
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);
}
}

View File

@ -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<bool> 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<AdapterAcceptanceResult> RunAsync(
string workRoot,
IProgress<string> 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;
}
}

View File

@ -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<bool> 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);
}
}

View File

@ -0,0 +1,2 @@
global using System.IO;
global using System.Net.Http;

View File

@ -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<object>() },
workspace = (object?)null,
input_transfers = Array.Empty<object>(),
});
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<string> 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<string> 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<AdapterAcceptanceResult> RunLocalAcceptanceAsync(
string workRoot,
IProgress<string> progress,
CancellationToken cancellationToken) =>
throw new NotSupportedException();
}
}

View File

@ -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<JobDisplaySnapshot> Snapshots { get; set; } = snapshots;
internal Exception? Failure { get; set; }
public IReadOnlyList<JobDisplaySnapshot> ReadSnapshots(int limit = 50)
{
if (Failure is not null) throw Failure;
return Snapshots;
}
}
}

View File

@ -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();
}
}

View File

@ -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<object>(),
}));
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<InvalidDataException>(() => 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);
}

View File

@ -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<InvalidDataException>(
() => 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<InvalidDataException>(() => JobStateMachine.EnsureTransition(
JobStateMachine.Cancelled,
JobStateMachine.DownloadingInputs,
JobTransitionMode.Recovery));
}
}

View File

@ -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<FakeConnection>();
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<FakeConnection>();
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<FakeConnection>();
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<FakeConnection>();
using var controller = CreateController(
store,
connections,
migrate: (_, _, _) => throw new IOException("copy failed"));
controller.Start();
await Assert.ThrowsAsync<IOException>(
() => 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<FakeConnection>();
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<FakeConnection> connections,
Func<string, string, CancellationToken, Task<NodeDataMigrationResult>>? 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++;
}
}

View File

@ -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<NodeConfigurationException>(
() => client.ReceiveAsync(CancellationToken.None));
Assert.Contains("节点身份已被服务端拒绝", exception.Message);
Assert.Contains("disabled", exception.Message);
}
private sealed class TestWebSocket : WebSocket
{
private readonly ConcurrentQueue<ReceiveFrame> 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<byte[]> 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<WebSocketReceiveResult> ReceiveAsync(
ArraySegment<byte> 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<byte> 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;
}
}
}
}

View File

@ -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<JsonException>(() => NodeProtocolCodec.Decode("{"u8.ToArray()));
}
}

View File

@ -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<string>();
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<NodeProtocolClient>(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<string> 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<WebSocketReceiveResult> ReceiveAsync(
ArraySegment<byte> buffer,
CancellationToken cancellationToken) => Task.FromResult(
new WebSocketReceiveResult(
0,
WebSocketMessageType.Close,
true,
WebSocketCloseStatus.NormalClosure,
"done"));
public override Task SendAsync(
ArraySegment<byte> buffer,
WebSocketMessageType messageType,
bool endOfMessage,
CancellationToken cancellationToken)
{
var message = NodeProtocolCodec.Decode(buffer.ToArray());
SentTypes.Add(message!.Type);
return Task.CompletedTask;
}
}
}

View File

@ -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<InvalidDataException>(
() => PathGuard.CombineUnderRoot(root, "..", "outside.json"));
Assert.Throws<InvalidDataException>(
() => 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);
}
}
}

View File

@ -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<TextBlock>(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<TextBlock>(window, "PageTitleText").Text);
Assert.Equal(
@"C:\node-data",
FindElement<TextBox>(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<EnrollOptions>(
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<Exception>(
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<bool> 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<FrameworkElement>(window, name);
private static T FindElement<T>(MainWindow window, string name)
where T : FrameworkElement =>
Assert.IsAssignableFrom<T>(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<IReadOnlyList<SoftwareManagementSnapshot>> RefreshAsync(
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<SoftwareManagementSnapshot>>([]);
public void SaveLocation(string softwareId, string path) { }
public void ClearLocation(string softwareId) { }
public Task<RuntimeInstallResult> InstallRuntimeAsync(
string softwareId,
IProgress<string> progress,
CancellationToken cancellationToken) =>
throw new NotSupportedException();
}
private sealed class EmptyJobMonitorService : IJobMonitorService
{
public IReadOnlyList<JobDisplaySnapshot> ReadSnapshots(int limit = 50) => [];
}
private sealed class EmptyAnsysAcceptanceService : IAnsysAcceptanceService
{
public bool IsAvailable => false;
public bool IsGateEnabled => false;
public Task<AdapterAcceptanceResult> RunAsync(
string workRoot,
IProgress<string> 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;
}
}

View File

@ -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<bool> 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<IReadOnlyList<SoftwareManagementSnapshot>> RefreshAsync(
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<SoftwareManagementSnapshot>>(snapshots);
public void SaveLocation(string softwareId, string path) =>
SavedPath = (softwareId, path);
public void ClearLocation(string softwareId) => ClearedId = softwareId;
public async Task<RuntimeInstallResult> InstallRuntimeAsync(
string softwareId,
IProgress<string> 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", "安装完成");
}
}
}

View File

@ -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<HttpResponseMessage> 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,
});
}
}
}

View File

@ -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);
}

View File

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Zcbot.WindowsNode\Zcbot.WindowsNode.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@ -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, )"
}
}
}
}
}

View File

@ -1,3 +1,4 @@
<Solution>
<Project Path="Zcbot.WindowsNode.Tests/Zcbot.WindowsNode.Tests.csproj" />
<Project Path="Zcbot.WindowsNode/Zcbot.WindowsNode.csproj" />
</Solution>

View File

@ -0,0 +1,59 @@
namespace Zcbot.WindowsNode;
internal sealed class AdapterCatalog
{
private static readonly Lazy<IReadOnlyList<AdapterDescriptor>> InstalledDescriptors =
new(DiscoverDescriptors, LazyThreadSafetyMode.ExecutionAndPublication);
private static readonly JobExecutionGate SharedExecutionGate = new();
private static readonly ProcessSupervisor SharedProcessSupervisor = new();
private readonly IReadOnlyDictionary<string, INodeAdapter> adapters;
internal AdapterCatalog(IEnumerable<INodeAdapter> 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<NodeAdapterContract> InstalledContracts =>
InstalledDescriptors.Value.Select(item => item.Contract).ToArray();
internal static IReadOnlyList<string> InstalledCapabilities =>
InstalledContracts.Select(item => item.Capability).ToArray();
internal static bool HasActiveExecution => SharedExecutionGate.IsActive;
internal IReadOnlyCollection<INodeAdapter> 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<AdapterDescriptor> 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();
}
}

View File

@ -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<string> 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);
}

View File

@ -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<Guid, Task> active = new();
private readonly ConcurrentDictionary<Guid, CancellationTokenSource> 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<string> CaptureOutputAsync(
StreamReader reader, IProgress<string>? 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<string> PrefixArguments);
}

View File

@ -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<IDisposable> 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();
}
}

View File

@ -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<string> CaptureOutputAsync(
StreamReader reader,
IProgress<string>? 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();
}
}

View File

@ -0,0 +1,76 @@
using System.Security;
using System.Text.Json;
namespace Zcbot.WindowsNode;
internal interface IAnsysAcceptanceService
{
bool IsAvailable { get; }
bool IsGateEnabled { get; }
Task<AdapterAcceptanceResult> RunAsync(
string workRoot,
IProgress<string> 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<AdapterAcceptanceResult> RunAsync(
string workRoot,
IProgress<string> 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;
}
}
}

View File

@ -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);
}

View File

@ -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";
}

View File

@ -0,0 +1,189 @@
using System.Text.Json;
namespace Zcbot.WindowsNode;
internal interface IJobMonitorService
{
IReadOnlyList<JobDisplaySnapshot> ReadSnapshots(int limit = 50);
}
internal sealed class JobMonitorService(string jobsDirectory) : IJobMonitorService
{
public IReadOnlyList<JobDisplaySnapshot> ReadSnapshots(int limit = 50)
{
if (!Directory.Exists(jobsDirectory))
{
return [];
}
var snapshots = new List<JobDisplaySnapshot>();
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;
}

View File

@ -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<NodeConfig, Action<NodeStatus>, INodeConnection> connectionFactory;
private readonly Func<EnrollOptions, INodeConfigStore, CancellationToken, Task> enroll;
private readonly Func<string, string, CancellationToken, Task<NodeDataMigrationResult>> migrate;
private readonly Action<string> 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<NodeConfig, Action<NodeStatus>, INodeConnection> connectionFactory,
Func<EnrollOptions, INodeConfigStore, CancellationToken, Task> enroll,
Func<string, string, CancellationToken, Task<NodeDataMigrationResult>> migrate,
Action<string> saveDataRoot)
{
this.store = store;
this.paths = paths;
this.connectionFactory = connectionFactory;
this.enroll = enroll;
this.migrate = migrate;
this.saveDataRoot = saveDataRoot;
}
internal event Action<NodeStatus>? 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<bool> 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<NodeDataMigrationResult> 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();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@ -0,0 +1,28 @@
namespace Zcbot.WindowsNode;
internal sealed class NodeCompositionRoot
{
internal NodeCompositionRoot(NodePaths paths)
{
Paths = paths;
ConfigStore = new NodeConfigStore(paths);
}
internal NodePaths Paths { get; }
internal NodeConfigStore ConfigStore { get; }
internal NodeApplicationController CreateApplicationController() => 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);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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) =>

View File

@ -0,0 +1,2 @@
global using System.IO;
global using System.Net.Http;

View File

@ -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<NodeStatus>? 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();
}

View File

@ -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<object> runtimePayload;
private readonly Func<NodeProtocolClient, CancellationToken, Task> connected;
private readonly Func<NodeProtocolClient, CancellationToken, Task> heartbeat;
private readonly Func<NodeProtocolClient, NodeProtocolMessage, CancellationToken, Task> received;
private readonly Action<NodeState, string> report;
private readonly Func<NodeConfig, CancellationToken, Task<NodeProtocolClient>> connect;
private readonly Func<TimeSpan, CancellationToken, Task> delay;
internal NodeSession(
NodeConfig config,
Func<object> runtimePayload,
Func<NodeProtocolClient, CancellationToken, Task> connected,
Func<NodeProtocolClient, CancellationToken, Task> heartbeat,
Func<NodeProtocolClient, NodeProtocolMessage, CancellationToken, Task> received,
Action<NodeState, string> report,
Func<NodeConfig, CancellationToken, Task<NodeProtocolClient>>? connect = null,
Func<TimeSpan, CancellationToken, Task>? 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);
}
}
}

View File

@ -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<string> XyzPlotTypes =
["contour", "surface_3d", "ternary", "heatmap"];
private static readonly HashSet<string> 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<RecoverableJob> ReadRecoverableJobs()
{
if (!Directory.Exists(jobsDirectory))
{
return [];
}
var jobs = new List<RecoverableJob>();
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<RecoverableJob> ReadRecoverableJobs() =>
new JobRepository(jobsDirectory).ReadRecoverableJobs();
internal IReadOnlyList<JobDisplaySnapshot> ReadJobSnapshots(int limit = 50)
{
if (!Directory.Exists(jobsDirectory))
{
return [];
}
var snapshots = new List<JobDisplaySnapshot>();
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<JsonElement> 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<object>(),
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;

View File

@ -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)

View File

@ -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<string> 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<byte[]?> 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();

View File

@ -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<NodeStatus>? 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<Guid, Task> jobPipelines = new();
private readonly ConcurrentDictionary<Guid, Task> 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<NodeStatus>?
}
}
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<NodeStatus>?
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<object>(),
}, 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<object>(),
}, cancellationToken);
}
else if (message.Type == "job_export"
&& payload.ValueKind != JsonValueKind.Undefined)
{
StartExport(payload);
}
}
@ -380,22 +245,28 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
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<NodeStatus>?
}
}
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<NodeStatus>?
{
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<NodeStatus>?
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<NodeStatus>?
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<NodeStatus>?
}
}
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<NodeStatus>?
}
}
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<NodeStatus>?
},
};
}
public void Dispose()
{
forcedStop.Dispose();
httpClient.Dispose();
}
}

View File

@ -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<JobRecoveryDecision> 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;
}
}

View File

@ -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<RecoverableJob> ReadRecoverableJobs()
{
if (!Directory.Exists(JobsDirectory))
{
return [];
}
var jobs = new List<RecoverableJob>();
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<JsonElement> 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<object>(),
terminal_at = DateTimeOffset.UtcNow,
}, JobInboxStore.JsonOptions);
AtomicFile.Write(path, content, overwrite);
}
}

View File

@ -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<string, IReadOnlySet<string>> Transitions =
new Dictionary<string, IReadOnlySet<string>>(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<string> Stages { get; } =
Transitions.Keys.ToHashSet(StringComparer.Ordinal);
internal static IReadOnlySet<string> 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<string> Set(params string[] values) =>
values.ToHashSet(StringComparer.Ordinal);
}

View File

@ -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<string> 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<string, INodeAdapter> adapters;
internal NodeAdapterRegistry(IEnumerable<INodeAdapter> 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<NodeAdapterContract> InstalledContracts =>
DiscoverDescriptors().Select(item => item.Contract).ToArray();
internal static IReadOnlyList<string> InstalledCapabilities =>
InstalledContracts.Select(item => item.Capability).ToArray();
internal IReadOnlyCollection<INodeAdapter> 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<AdapterDescriptor> 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();
}
}

View File

@ -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))
{

View File

@ -1,4 +1,5 @@
using Microsoft.Win32;
using System.IO;
using System.Security;
using System.Security.Cryptography;

View File

@ -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);
}
}
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,54 @@
using System.Windows.Input;
namespace Zcbot.WindowsNode;
internal sealed class AsyncCommand : ObservableObject, ICommand
{
private readonly Func<Task> execute;
private readonly Func<bool>? canExecute;
private bool isRunning;
internal AsyncCommand(Func<Task> execute, Func<bool>? canExecute = null)
{
this.execute = execute;
this.canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
internal event Action<Exception>? 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);
}

View File

@ -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<T>(
ref T field,
T value,
[CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.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));
}

View File

@ -0,0 +1,14 @@
using System.Windows.Input;
namespace Zcbot.WindowsNode;
internal sealed class RelayCommand(Action execute, Func<bool>? 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);
}

View File

@ -0,0 +1,453 @@
<Window x:Class="Zcbot.WindowsNode.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="zcbot Windows Node"
Icon="pack://application:,,,/Zcbot.WindowsNode;component/Assets/zcbot.ico"
Width="1040"
Height="720"
MinWidth="760"
MinHeight="560"
WindowStartupLocation="CenterScreen">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
</Window.Resources>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="210" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="{StaticResource SidebarBrush}" Padding="18,26">
<DockPanel>
<StackPanel DockPanel.Dock="Top">
<Grid Margin="4,0,4,24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Width="34" Height="34" CornerRadius="9" Background="#FF1C4A42">
<TextBlock Text="Z" Foreground="#FF99F6E4" FontSize="18"
FontWeight="Bold" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="zcbot" Foreground="White" FontSize="19" FontWeight="SemiBold" />
<TextBlock Text="Windows Node" Foreground="#FF9EB8B1" FontSize="11" />
</StackPanel>
</Grid>
<TextBlock Text="管理" Foreground="#FF78958D" FontSize="11"
FontWeight="SemiBold" Margin="12,0,0,6" />
<ToggleButton Content="节点概览"
Command="{Binding ShowOverviewCommand}"
IsChecked="{Binding IsOverviewPage, Mode=OneWay}" />
<ToggleButton Content="专业软件"
Command="{Binding ShowSoftwareCommand}"
IsChecked="{Binding IsSoftwarePage, Mode=OneWay}" />
<ToggleButton Content="本机任务"
Command="{Binding ShowJobsCommand}"
IsChecked="{Binding IsJobsPage, Mode=OneWay}" />
<ToggleButton Content="运行设置"
Command="{Binding ShowSettingsCommand}"
IsChecked="{Binding IsSettingsPage, Mode=OneWay}" />
</StackPanel>
<Button DockPanel.Dock="Bottom"
Content="隐藏到托盘"
Command="{Binding HideCommand}"
Margin="0"
Style="{StaticResource SidebarButtonStyle}" />
</DockPanel>
</Border>
<Grid Grid.Column="1" Margin="{StaticResource PagePadding}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Margin="0,0,0,22">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock x:Name="PageTitleText"
Text="{Binding PageTitle}"
FontSize="27"
FontWeight="SemiBold" />
<TextBlock Text="Windows Node 在后台安全接收并执行专业软件任务。"
Foreground="{StaticResource MutedTextBrush}" Margin="0,5,0,0" />
</StackPanel>
<Border Grid.Column="1" Background="{StaticResource SurfaceBrush}"
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"
CornerRadius="16" Padding="11,6" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Margin="0,0,7,0">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="{StaticResource NeutralBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding StatusTone}" Value="success">
<Setter Property="Fill" Value="{StaticResource SuccessBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding StatusTone}" Value="warning">
<Setter Property="Fill" Value="{StaticResource WarningBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding StatusTone}" Value="error">
<Setter Property="Fill" Value="{StaticResource ErrorBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<TextBlock Text="{Binding StatusText}" FontSize="12" FontWeight="SemiBold" />
</StackPanel>
</Border>
</Grid>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled" Padding="0,0,8,0">
<Grid>
<StackPanel x:Name="OverviewPage"
Visibility="{Binding IsOverviewPage, Converter={StaticResource BooleanToVisibilityConverter}}">
<Border Style="{StaticResource CardBorderStyle}"
Margin="0,0,0,16">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="5" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border CornerRadius="3" Margin="0,0,0,0">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="{StaticResource NeutralBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding StatusTone}" Value="success">
<Setter Property="Background" Value="{StaticResource SuccessBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding StatusTone}" Value="warning">
<Setter Property="Background" Value="{StaticResource WarningBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding StatusTone}" Value="error">
<Setter Property="Background" Value="{StaticResource ErrorBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
<StackPanel Grid.Column="1" Margin="18,0,0,0">
<TextBlock Text="当前状态" Foreground="{StaticResource MutedTextBrush}" />
<TextBlock Text="{Binding StatusText}" FontSize="24" FontWeight="SemiBold" Margin="0,5,0,4" />
<TextBlock Text="{Binding StatusDetail}" TextWrapping="Wrap" />
<Separator Margin="0,18" Background="{StaticResource BorderBrush}" />
<TextBlock Text="节点身份" FontWeight="SemiBold" Margin="0,0,0,8" />
<TextBlock Text="{Binding IdentityText}" TextWrapping="Wrap" />
<WrapPanel Margin="0,18,0,0"
Visibility="{Binding IsRegistered, Converter={StaticResource BooleanToVisibilityConverter}}">
<Button Content="立即重连"
Command="{Binding ReconnectCommand}"
Style="{StaticResource PrimaryButtonStyle}" />
<Button Content="清除本机身份"
Command="{Binding ResetIdentityCommand}"
Style="{StaticResource DangerButtonStyle}" />
<Button Content="复制诊断信息"
Command="{Binding CopyDiagnosticsCommand}" />
</WrapPanel>
</StackPanel>
</Grid>
</Border>
<Border Style="{StaticResource CardBorderStyle}"
Visibility="{Binding IsRegistrationVisible, Converter={StaticResource BooleanToVisibilityConverter}}">
<StackPanel MaxWidth="660" HorizontalAlignment="Left">
<TextBlock Text="首次注册" Style="{StaticResource SectionTitleStyle}" />
<TextBlock Text="从 zcbot 管理后台生成一次性注册码;成功注册后注册码立即失效。"
Foreground="{StaticResource MutedTextBrush}"
TextWrapping="Wrap" Margin="0,6,0,18" />
<TextBlock Text="zcbot 服务地址" Margin="0,0,0,5" />
<TextBox Text="{Binding ServerUrl, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="节点名称" Margin="0,14,0,5" />
<TextBox Text="{Binding NodeName, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="一次性注册码" Margin="0,14,0,5" />
<PasswordBox x:Name="EnrollmentCodeBox"
PasswordChanged="EnrollmentCodeBox_OnPasswordChanged" />
<Button Content="注册并连接"
Command="{Binding RegisterCommand}"
Style="{StaticResource PrimaryButtonStyle}"
HorizontalAlignment="Left"
Margin="0,18,0,0" />
<TextBlock Text="Node Token 使用 Windows DPAPI 加密保存,不在界面显示。"
Foreground="{StaticResource MutedTextBrush}"
TextWrapping="Wrap" Margin="0,14,0,0" />
</StackPanel>
</Border>
</StackPanel>
<StackPanel x:Name="SoftwarePage"
Visibility="{Binding IsSoftwarePage, Converter={StaticResource BooleanToVisibilityConverter}}">
<Grid Margin="0,0,0,14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel DockPanel.Dock="Left">
<TextBlock Text="本机专业软件" Style="{StaticResource SectionTitleStyle}" />
<TextBlock Text="应用位置与各自的 Python 运行环境相互独立。"
Foreground="{StaticResource MutedTextBrush}"
Margin="0,4,0,0" />
</StackPanel>
<WrapPanel Grid.Column="1" HorizontalAlignment="Right">
<Button Content="重新检测" Command="{Binding SoftwarePage.RefreshCommand}" />
<Button Content="取消安装"
Command="{Binding SoftwarePage.CancelInstallCommand}"
Visibility="{Binding SoftwarePage.IsInstalling, Converter={StaticResource BooleanToVisibilityConverter}}" />
</WrapPanel>
</Grid>
<ProgressBar Height="3" IsIndeterminate="True" Margin="0,0,0,10"
Visibility="{Binding SoftwarePage.IsBusy, Converter={StaticResource BooleanToVisibilityConverter}}" />
<TextBlock Text="{Binding SoftwarePage.Message}"
Foreground="{StaticResource PrimaryBrush}"
TextWrapping="Wrap"
Margin="0,0,0,14" />
<ItemsControl ItemsSource="{Binding SoftwarePage.Cards}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Style="{StaticResource CardBorderStyle}"
Margin="0,0,0,14">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding DisplayName}"
FontSize="19"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Border Grid.Column="1" CornerRadius="12" Padding="9,4">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="{StaticResource ErrorSurfaceBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding LocationAvailable}" Value="True">
<Setter Property="Background" Value="{StaticResource SuccessSurfaceBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock FontSize="11" FontWeight="SemiBold">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="未检测到" />
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding LocationAvailable}" Value="True">
<Setter Property="Text" Value="已检测" />
<Setter Property="Foreground" Value="{StaticResource SuccessBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Border>
</Grid>
<TextBox Text="{Binding LocationText, Mode=OneWay}"
IsReadOnly="True"
Margin="0,12,0,8" />
<TextBlock Text="{Binding StatusText}"
Foreground="{StaticResource MutedTextBrush}"
TextWrapping="Wrap" />
<WrapPanel Margin="0,16,0,0">
<Button Content="选择位置" Command="{Binding ChooseLocationCommand}" />
<Button Content="自动检测" Command="{Binding ClearLocationCommand}" />
<Button Content="安装 / 更新环境"
Command="{Binding InstallRuntimeCommand}"
Style="{StaticResource PrimaryButtonStyle}" />
</WrapPanel>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Border Style="{StaticResource CardBorderStyle}"
Visibility="{Binding AcceptancePage.IsAvailable, Converter={StaticResource BooleanToVisibilityConverter}}">
<StackPanel>
<TextBlock Text="ANSYS Mechanical 真机验收"
Style="{StaticResource SectionTitleStyle}" />
<TextBlock Text="使用内置标准试件验证完整进程树取消、连续 20 次真实静力求解和结果释放。"
Foreground="{StaticResource MutedTextBrush}"
TextWrapping="Wrap"
Margin="0,6,0,12" />
<ProgressBar Height="3"
IsIndeterminate="True"
Margin="0,0,0,12"
Visibility="{Binding AcceptancePage.IsRunning, Converter={StaticResource BooleanToVisibilityConverter}}" />
<TextBlock Text="{Binding AcceptancePage.Message}"
TextWrapping="Wrap">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource MutedTextBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding AcceptancePage.Tone}" Value="success">
<Setter Property="Foreground" Value="{StaticResource SuccessBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding AcceptancePage.Tone}" Value="warning">
<Setter Property="Foreground" Value="{StaticResource WarningBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding AcceptancePage.Tone}" Value="error">
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<WrapPanel Margin="0,16,0,0">
<Button Content="运行内置基准验收"
Command="{Binding AcceptancePage.RunCommand}"
Style="{StaticResource PrimaryButtonStyle}" />
<Button Content="停止验收"
Command="{Binding AcceptancePage.CancelCommand}"
Visibility="{Binding AcceptancePage.IsRunning, Converter={StaticResource BooleanToVisibilityConverter}}" />
<Button Content="开启 ANSYS 执行门"
Command="{Binding AcceptancePage.EnableGateCommand}" />
</WrapPanel>
</StackPanel>
</Border>
</StackPanel>
<StackPanel x:Name="JobsPage"
Visibility="{Binding IsJobsPage, Converter={StaticResource BooleanToVisibilityConverter}}">
<Grid Margin="0,0,0,14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="本机任务" Style="{StaticResource SectionTitleStyle}" />
<TextBlock Text="仅显示已派发到本机的任务;断线或重启后仍可查看。"
Foreground="{StaticResource MutedTextBrush}"
Margin="0,4,0,0" />
</StackPanel>
<Button Grid.Column="1"
Content="立即刷新"
Command="{Binding JobPage.RefreshCommand}"
HorizontalAlignment="Right" />
</Grid>
<TextBlock Text="{Binding JobPage.Summary}"
Foreground="{StaticResource PrimaryBrush}"
Margin="0,0,0,12" />
<Border Style="{StaticResource CardBorderStyle}"
Padding="0"
Margin="0,0,0,14">
<DataGrid ItemsSource="{Binding JobPage.Jobs}"
SelectedItem="{Binding JobPage.SelectedJob, Mode=TwoWay}"
AutoGenerateColumns="False"
CanUserAddRows="False"
CanUserDeleteRows="False"
CanUserReorderColumns="False"
CanUserResizeRows="False"
IsReadOnly="True"
SelectionMode="Single"
SelectionUnit="FullRow"
HeadersVisibility="Column"
GridLinesVisibility="Horizontal"
BorderThickness="0"
Background="Transparent"
MinHeight="180"
MaxHeight="340">
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding Tone}" Value="success">
<Setter Property="Foreground" Value="{StaticResource SuccessBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding Tone}" Value="error">
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding Tone}" Value="neutral">
<Setter Property="Foreground" Value="{StaticResource MutedTextBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</DataGrid.RowStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="状态" Binding="{Binding StageText}" Width="95" />
<DataGridTextColumn Header="任务" Binding="{Binding Title}" Width="*" MinWidth="150" />
<DataGridTextColumn Header="输入" Binding="{Binding InputFilename}" Width="150" />
<DataGridTextColumn Header="进度" Binding="{Binding ProgressText}" Width="90" />
<DataGridTextColumn Header="接收时间" Binding="{Binding AcceptedAtText}" Width="105" />
<DataGridTextColumn Header="Job ID" Binding="{Binding ShortId}" Width="80" />
</DataGrid.Columns>
</DataGrid>
</Border>
<Border Style="{StaticResource CardBorderStyle}">
<StackPanel>
<TextBlock Text="任务详情" Style="{StaticResource SectionTitleStyle}" />
<TextBlock Text="{Binding JobPage.SelectedDetail}"
TextWrapping="Wrap"
Margin="0,12,0,0" />
</StackPanel>
</Border>
</StackPanel>
<StackPanel x:Name="SettingsPage"
Visibility="{Binding IsSettingsPage, Converter={StaticResource BooleanToVisibilityConverter}}">
<Border Style="{StaticResource CardBorderStyle}"
Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="后台运行" Style="{StaticResource SectionTitleStyle}" />
<CheckBox Content="登录 Windows 后自动启动节点"
IsChecked="{Binding IsStartupEnabled, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Margin="0,16,0,4" />
<TextBlock Text="开机启动保存到当前 Windows 账号,不安装系统服务或计划任务。"
Foreground="{StaticResource MutedTextBrush}"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Style="{StaticResource CardBorderStyle}">
<StackPanel>
<TextBlock Text="数据目录" Style="{StaticResource SectionTitleStyle}" />
<TextBox x:Name="DataRootText"
Text="{Binding DataRootPage.CurrentRoot, Mode=OneWay}"
IsReadOnly="True"
Margin="0,14,0,0" />
<TextBlock Text="保存节点身份、任务、Workspace 和受管 runtime。迁移前需要检查活动任务、磁盘和文件校验。"
Foreground="{StaticResource MutedTextBrush}"
TextWrapping="Wrap" Margin="0,10,0,16" />
<TextBlock Text="{Binding DataRootPage.Status}"
Foreground="{StaticResource MutedTextBrush}"
TextWrapping="Wrap"
Margin="0,0,0,16" />
<ProgressBar Height="3"
IsIndeterminate="True"
Margin="0,0,0,16"
Visibility="{Binding DataRootPage.IsMigrating, Converter={StaticResource BooleanToVisibilityConverter}}" />
<WrapPanel>
<Button Content="更改并迁移"
Command="{Binding DataRootPage.ChangeCommand}"
Style="{StaticResource PrimaryButtonStyle}" />
<Button Content="打开目录"
Command="{Binding DataRootPage.OpenCommand}" />
</WrapPanel>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</ScrollViewer>
<StackPanel Grid.Row="2" Margin="0,16,0,0">
<Border Background="{StaticResource SurfaceMutedBrush}"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1"
CornerRadius="8"
Padding="11,8"
Margin="0,0,0,8"
Visibility="{Binding HasOperationMessage, Converter={StaticResource BooleanToVisibilityConverter}}">
<TextBlock Text="{Binding OperationMessage}"
Foreground="{StaticResource PrimaryBrush}"
TextWrapping="Wrap" />
</Border>
<TextBlock Text="关闭窗口不会退出节点;可从系统托盘再次打开。"
Foreground="{StaticResource MutedTextBrush}" FontSize="12" />
</StackPanel>
</Grid>
</Grid>
</Window>

View File

@ -0,0 +1,83 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Threading;
namespace Zcbot.WindowsNode;
public partial class MainWindow : Window
{
private readonly DispatcherTimer jobRefreshTimer;
internal MainWindow(MainWindowViewModel viewModel)
{
InitializeComponent();
ViewModel = viewModel;
DataContext = viewModel;
ViewModel.PropertyChanged += ViewModel_OnPropertyChanged;
jobRefreshTimer = new DispatcherTimer(DispatcherPriority.Background, Dispatcher)
{
Interval = TimeSpan.FromSeconds(1),
};
jobRefreshTimer.Tick += (_, _) =>
{
if (IsVisible && ViewModel.IsJobsPage)
{
ViewModel.JobPage.Refresh();
}
};
jobRefreshTimer.Start();
}
internal MainWindowViewModel ViewModel { get; }
internal bool AllowClose { get; set; }
protected override void OnClosing(CancelEventArgs eventArgs)
{
if (!AllowClose)
{
eventArgs.Cancel = true;
Hide();
}
base.OnClosing(eventArgs);
}
protected override void OnClosed(EventArgs eventArgs)
{
jobRefreshTimer.Stop();
ViewModel.PropertyChanged -= ViewModel_OnPropertyChanged;
base.OnClosed(eventArgs);
}
internal void ShowAndActivate()
{
if (!IsVisible) Show();
if (WindowState == WindowState.Minimized) WindowState = WindowState.Normal;
Activate();
}
private void EnrollmentCodeBox_OnPasswordChanged(
object sender,
System.Windows.RoutedEventArgs eventArgs)
{
if (!string.Equals(
ViewModel.EnrollmentCode,
EnrollmentCodeBox.Password,
StringComparison.Ordinal))
{
ViewModel.EnrollmentCode = EnrollmentCodeBox.Password;
}
}
private void ViewModel_OnPropertyChanged(object? sender, PropertyChangedEventArgs eventArgs)
{
if (eventArgs.PropertyName == nameof(MainWindowViewModel.EnrollmentCode)
&& !string.Equals(
EnrollmentCodeBox.Password,
ViewModel.EnrollmentCode,
StringComparison.Ordinal))
{
EnrollmentCodeBox.Password = ViewModel.EnrollmentCode;
}
}
}

View File

@ -0,0 +1,146 @@
namespace Zcbot.WindowsNode;
internal sealed record SoftwareManagementSnapshot(
string Id,
string DisplayName,
SoftwarePathKind PathKind,
string RequiredRelativePath,
bool LocationAvailable,
string LocationText,
string StatusText,
bool RuntimeInstalled,
bool HasActiveJobs);
internal interface ISoftwareManagementService
{
Task<IReadOnlyList<SoftwareManagementSnapshot>> RefreshAsync(
CancellationToken cancellationToken);
void SaveLocation(string softwareId, string path);
void ClearLocation(string softwareId);
Task<RuntimeInstallResult> InstallRuntimeAsync(
string softwareId,
IProgress<string> progress,
CancellationToken cancellationToken);
}
internal sealed class SoftwareManagementService : ISoftwareManagementService
{
private readonly NodePaths paths;
private readonly AdapterCatalog adapters;
internal SoftwareManagementService(NodePaths paths)
{
this.paths = paths;
adapters = AdapterCatalog.CreateDefault(
new JobRepository(paths.JobsDirectory),
paths);
}
public Task<IReadOnlyList<SoftwareManagementSnapshot>> RefreshAsync(
CancellationToken cancellationToken) =>
Task.Run<IReadOnlyList<SoftwareManagementSnapshot>>(
CreateSnapshots,
cancellationToken);
public void SaveLocation(string softwareId, string path)
{
var definition = Definition(softwareId);
SoftwareLocationService.SaveConfiguredPath(definition, path);
adapters.InvalidateRuntime(definition.RuntimeId);
}
public void ClearLocation(string softwareId)
{
var definition = Definition(softwareId);
SoftwareLocationService.ClearConfiguredPath(definition);
adapters.InvalidateRuntime(definition.RuntimeId);
}
public async Task<RuntimeInstallResult> InstallRuntimeAsync(
string softwareId,
IProgress<string> progress,
CancellationToken cancellationToken)
{
var definition = Definition(softwareId);
var result = await ManagedRuntimeInstaller.InstallAsync(
definition,
progress,
cancellationToken);
adapters.InvalidateRuntime(definition.RuntimeId);
return result;
}
private IReadOnlyList<SoftwareManagementSnapshot> CreateSnapshots()
{
return SoftwareCatalog.Definitions.Select(definition =>
{
var location = SoftwareLocationService.ResolveLocation(definition);
var runtimePath = Path.Combine(
paths.RootDirectory,
"runtimes",
definition.RuntimeId,
"Scripts",
"python.exe");
var runtimeInstalled = File.Exists(runtimePath);
var matchingAdapters = adapters.All.Where(item =>
definition.RuntimeId.Equals(item.RuntimeId, StringComparison.Ordinal)).ToArray();
var hasActiveJobs = AdapterCatalog.HasActiveExecution
|| matchingAdapters.Any(item => item.HasActiveJobs);
var status = BuildStatus(
location,
runtimeInstalled,
matchingAdapters);
return new SoftwareManagementSnapshot(
definition.Id,
definition.DisplayName,
definition.PathKind,
definition.RequiredRelativePath ?? string.Empty,
location.Available,
location.Path ?? "未检测到应用位置",
status,
runtimeInstalled,
hasActiveJobs);
}).ToArray();
}
private static string BuildStatus(
SoftwareLocation location,
bool runtimeInstalled,
IReadOnlyList<INodeAdapter> matchingAdapters)
{
var runtime = runtimeInstalled ? "运行环境已安装" : "运行环境尚未安装";
if (!location.Available)
{
return $"{runtime} · {location.Detail}";
}
if (!runtimeInstalled || matchingAdapters.Count == 0)
{
return $"{runtime} · {location.Source}";
}
var probes = matchingAdapters.Select(item => item.DetectRuntime()).ToArray();
var health = probes.All(item => item.Health == "ready")
? "可用"
: probes.Any(item => item.Health == "ready") ? "部分可用" : "需配置";
var versions = probes
.Select(item => item.SoftwareVersion)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray();
var version = versions.Length == 0 ? string.Empty : $" · {string.Join(" / ", versions)}";
var detail = string.Join("", probes
.Where(item => item.Health != "ready")
.Select(item => item.Detail)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal));
return string.IsNullOrWhiteSpace(detail)
? $"{runtime} · {health}{version} · {location.Source}"
: $"{runtime} · {health}{version} · {detail}";
}
private static SoftwareDefinition Definition(string softwareId) =>
SoftwareCatalog.Definitions.Single(item =>
item.Id.Equals(softwareId, StringComparison.Ordinal));
}

View File

@ -0,0 +1,238 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Color x:Key="WindowBackgroundColor">#FFF4F7F6</Color>
<Color x:Key="SurfaceColor">#FFFFFFFF</Color>
<Color x:Key="SurfaceMutedColor">#FFF0F5F3</Color>
<Color x:Key="PrimaryColor">#FF0F766E</Color>
<Color x:Key="PrimaryHoverColor">#FF0B5F59</Color>
<Color x:Key="PrimaryPressedColor">#FF084C47</Color>
<Color x:Key="TextColor">#FF17201E</Color>
<Color x:Key="MutedTextColor">#FF64716E</Color>
<Color x:Key="BorderColor">#FFD9E3E0</Color>
<Color x:Key="ControlHoverColor">#FFEAF1EF</Color>
<Color x:Key="SidebarColor">#FF0E2824</Color>
<Color x:Key="SidebarHoverColor">#FF183A34</Color>
<Color x:Key="SidebarSelectedColor">#FF225046</Color>
<Color x:Key="SuccessColor">#FF15803D</Color>
<Color x:Key="WarningColor">#FFB45309</Color>
<Color x:Key="ErrorColor">#FFB91C1C</Color>
<Color x:Key="NeutralColor">#FF64748B</Color>
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="{StaticResource WindowBackgroundColor}" />
<SolidColorBrush x:Key="SurfaceBrush" Color="{StaticResource SurfaceColor}" />
<SolidColorBrush x:Key="SurfaceMutedBrush" Color="{StaticResource SurfaceMutedColor}" />
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource PrimaryColor}" />
<SolidColorBrush x:Key="PrimaryHoverBrush" Color="{StaticResource PrimaryHoverColor}" />
<SolidColorBrush x:Key="PrimaryPressedBrush" Color="{StaticResource PrimaryPressedColor}" />
<SolidColorBrush x:Key="TextBrush" Color="{StaticResource TextColor}" />
<SolidColorBrush x:Key="MutedTextBrush" Color="{StaticResource MutedTextColor}" />
<SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}" />
<SolidColorBrush x:Key="ControlHoverBrush" Color="{StaticResource ControlHoverColor}" />
<SolidColorBrush x:Key="SidebarBrush" Color="{StaticResource SidebarColor}" />
<SolidColorBrush x:Key="SidebarHoverBrush" Color="{StaticResource SidebarHoverColor}" />
<SolidColorBrush x:Key="SidebarSelectedBrush" Color="{StaticResource SidebarSelectedColor}" />
<SolidColorBrush x:Key="SuccessBrush" Color="{StaticResource SuccessColor}" />
<SolidColorBrush x:Key="WarningBrush" Color="{StaticResource WarningColor}" />
<SolidColorBrush x:Key="ErrorBrush" Color="{StaticResource ErrorColor}" />
<SolidColorBrush x:Key="NeutralBrush" Color="{StaticResource NeutralColor}" />
<SolidColorBrush x:Key="SuccessSurfaceBrush" Color="#FFF0FDF4" />
<SolidColorBrush x:Key="WarningSurfaceBrush" Color="#FFFFFBEB" />
<SolidColorBrush x:Key="ErrorSurfaceBrush" Color="#FFFEF2F2" />
<SolidColorBrush x:Key="NeutralSurfaceBrush" Color="#FFF1F5F9" />
<Thickness x:Key="PagePadding">32,28</Thickness>
<Thickness x:Key="CardPadding">24</Thickness>
<CornerRadius x:Key="CardCornerRadius">14</CornerRadius>
<Style TargetType="Window">
<Setter Property="FontFamily" Value="Microsoft YaHei UI" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="Background" Value="{StaticResource WindowBackgroundBrush}" />
<Setter Property="TextOptions.TextFormattingMode" Value="Display" />
<Setter Property="TextOptions.TextRenderingMode" Value="ClearType" />
</Style>
<Style TargetType="Button">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="Background" Value="{StaticResource SurfaceBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="16,9" />
<Setter Property="MinHeight" Value="40" />
<Setter Property="Margin" Value="0,0,10,0" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Chrome"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8"
SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Margin="{TemplateBinding Padding}"
RecognizesAccessKey="True" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Chrome" Property="Opacity" Value="0.45" />
<Setter Property="Cursor" Value="Arrow" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Chrome" Property="BorderBrush" Value="{StaticResource PrimaryBrush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource ControlHoverBrush}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="{StaticResource BorderBrush}" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="PrimaryButtonStyle"
TargetType="Button"
BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Foreground" Value="White" />
<Setter Property="Background" Value="{StaticResource PrimaryBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource PrimaryBrush}" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource PrimaryHoverBrush}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="{StaticResource PrimaryPressedBrush}" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="DangerButtonStyle"
TargetType="Button"
BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}" />
<Setter Property="Background" Value="{StaticResource ErrorSurfaceBrush}" />
<Setter Property="BorderBrush" Value="#FFFECACA" />
</Style>
<Style x:Key="SidebarButtonStyle"
TargetType="Button"
BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Foreground" Value="#FFD4E3DF" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="Margin" Value="0,2" />
<Setter Property="Padding" Value="13,11" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource SidebarHoverBrush}" />
<Setter Property="Foreground" Value="White" />
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="ToggleButton">
<Setter Property="Foreground" Value="#FFD4E3DF" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="Padding" Value="13,11" />
<Setter Property="Margin" Value="0,2" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="Chrome"
Background="{TemplateBinding Background}"
CornerRadius="8">
<Grid>
<Border x:Name="Indicator"
Width="3"
Margin="0,8,0,8"
Background="Transparent"
CornerRadius="2"
HorizontalAlignment="Left" />
<ContentPresenter Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="Center" />
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Chrome" Property="Background" Value="{StaticResource SidebarHoverBrush}" />
<Setter Property="Foreground" Value="White" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Chrome" Property="Background" Value="{StaticResource SidebarSelectedBrush}" />
<Setter TargetName="Indicator" Property="Background" Value="#FF5EEAD4" />
<Setter Property="Foreground" Value="White" />
<Setter Property="FontWeight" Value="SemiBold" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Chrome" Property="BorderBrush" Value="#FF5EEAD4" />
<Setter TargetName="Chrome" Property="BorderThickness" Value="1" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="TextBox">
<Setter Property="Padding" Value="11,8" />
<Setter Property="MinHeight" Value="40" />
<Setter Property="Background" Value="{StaticResource SurfaceBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="SelectionBrush" Value="{StaticResource PrimaryBrush}" />
<Style.Triggers>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource PrimaryBrush}" />
</Trigger>
<Trigger Property="IsReadOnly" Value="True">
<Setter Property="Background" Value="{StaticResource SurfaceMutedBrush}" />
<Setter Property="Foreground" Value="{StaticResource MutedTextBrush}" />
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="PasswordBox">
<Setter Property="Padding" Value="11,8" />
<Setter Property="MinHeight" Value="40" />
<Setter Property="Background" Value="{StaticResource SurfaceBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Style.Triggers>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource PrimaryBrush}" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="CardBorderStyle" TargetType="Border">
<Setter Property="Background" Value="{StaticResource SurfaceBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{StaticResource CardCornerRadius}" />
<Setter Property="Padding" Value="{StaticResource CardPadding}" />
</Style>
<Style x:Key="SectionTitleStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="20" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style TargetType="ProgressBar">
<Setter Property="Foreground" Value="{StaticResource PrimaryBrush}" />
<Setter Property="Background" Value="{StaticResource SurfaceMutedBrush}" />
<Setter Property="BorderThickness" Value="0" />
</Style>
</ResourceDictionary>

View File

@ -0,0 +1,359 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using WinForms = System.Windows.Forms;
namespace Zcbot.WindowsNode;
internal sealed class TrayHost : IDisposable
{
private readonly NodeApplicationController controller;
private readonly MainWindow window;
private readonly Action shutdown;
private readonly WinForms.NotifyIcon tray;
private readonly WinForms.ToolStripMenuItem statusItem;
private readonly WinForms.ToolStripMenuItem exitItem;
private NodeStatus current = NodeStatus.Create(NodeState.NotRegistered, "尚未注册");
private bool exiting;
private bool disposed;
internal TrayHost(
NodeApplicationController controller,
MainWindow window,
Action shutdown)
{
this.controller = controller;
this.window = window;
this.shutdown = shutdown;
statusItem = new WinForms.ToolStripMenuItem("尚未注册") { Enabled = false };
exitItem = new WinForms.ToolStripMenuItem("退出节点");
exitItem.Click += async (_, _) => await ExitNodeAsync();
var menu = new WinForms.ContextMenuStrip();
menu.Items.Add(statusItem);
menu.Items.Add(new WinForms.ToolStripSeparator());
menu.Items.Add("打开主窗口", null, (_, _) => ShowMainWindow());
menu.Items.Add("立即重连", null, (_, _) => RestartConnection());
menu.Items.Add(new WinForms.ToolStripSeparator());
menu.Items.Add(exitItem);
tray = new WinForms.NotifyIcon
{
ContextMenuStrip = menu,
Icon = TrayIconFactory.Create(NodeState.NotRegistered),
Text = "zcbot Windows Node - 尚未注册",
Visible = true,
};
tray.DoubleClick += (_, _) => ShowMainWindow();
window.ViewModel.CopyDiagnosticsRequested += CopyDiagnostics;
window.ViewModel.HideRequested += window.Hide;
window.ViewModel.ExitRequested += ExitNodeAsync;
window.ViewModel.RegisterRequested += RegisterFromWpfAsync;
window.ViewModel.ReconnectRequested += ReconnectFromWpfAsync;
window.ViewModel.ResetIdentityRequested += ResetIdentityFromWpfAsync;
window.ViewModel.SoftwarePage.ChooseLocationRequested += ChooseSoftwareLocation;
window.ViewModel.SoftwarePage.ConfirmInstallRequested += ConfirmRuntimeInstall;
window.ViewModel.AcceptancePage.ChooseReportDirectoryRequested +=
ChooseAcceptanceReportDirectory;
window.ViewModel.AcceptancePage.ConfirmRunRequested += ConfirmAnsysAcceptance;
window.ViewModel.AcceptancePage.ConfirmEnableGateRequested += ConfirmEnableAnsysGate;
window.ViewModel.DataRootPage.ChooseTargetRequested += ChooseDataRootTarget;
window.ViewModel.DataRootPage.ConfirmMigrationRequested += ConfirmDataRootMigration;
window.ViewModel.DataRootPage.MigrationRequested += MigrateDataRootFromWpfAsync;
window.ViewModel.DataRootPage.MigrationCompleted += CompleteDataRootMigration;
window.ViewModel.DataRootPage.OpenRequested += OpenDataRoot;
controller.StatusChanged += PostStatus;
}
internal void Start()
{
controller.Start();
UpdateStatus(controller.CurrentStatus);
if (current.State is NodeState.NotRegistered or NodeState.AuthenticationRequired)
{
ShowMainWindow();
}
}
public void Dispose()
{
if (disposed) return;
disposed = true;
controller.StatusChanged -= PostStatus;
window.ViewModel.CopyDiagnosticsRequested -= CopyDiagnostics;
window.ViewModel.HideRequested -= window.Hide;
window.ViewModel.ExitRequested -= ExitNodeAsync;
window.ViewModel.RegisterRequested -= RegisterFromWpfAsync;
window.ViewModel.ReconnectRequested -= ReconnectFromWpfAsync;
window.ViewModel.ResetIdentityRequested -= ResetIdentityFromWpfAsync;
window.ViewModel.SoftwarePage.ChooseLocationRequested -= ChooseSoftwareLocation;
window.ViewModel.SoftwarePage.ConfirmInstallRequested -= ConfirmRuntimeInstall;
window.ViewModel.AcceptancePage.ChooseReportDirectoryRequested -=
ChooseAcceptanceReportDirectory;
window.ViewModel.AcceptancePage.ConfirmRunRequested -= ConfirmAnsysAcceptance;
window.ViewModel.AcceptancePage.ConfirmEnableGateRequested -= ConfirmEnableAnsysGate;
window.ViewModel.DataRootPage.ChooseTargetRequested -= ChooseDataRootTarget;
window.ViewModel.DataRootPage.ConfirmMigrationRequested -= ConfirmDataRootMigration;
window.ViewModel.DataRootPage.MigrationRequested -= MigrateDataRootFromWpfAsync;
window.ViewModel.DataRootPage.MigrationCompleted -= CompleteDataRootMigration;
window.ViewModel.DataRootPage.OpenRequested -= OpenDataRoot;
window.ViewModel.Dispose();
controller.Dispose();
tray.Visible = false;
tray.Icon?.Dispose();
tray.Dispose();
}
private void PostStatus(NodeStatus status)
{
if (window.Dispatcher.CheckAccess())
{
UpdateStatus(status);
}
else
{
_ = window.Dispatcher.InvokeAsync(() => UpdateStatus(status));
}
}
private void UpdateStatus(NodeStatus status)
{
current = status;
window.ViewModel.ApplyStatus(status, controller.CurrentConfig);
statusItem.Text = CompactMenuText(status.Message, 24);
tray.Text = Limit($"zcbot Windows Node - {status.Message}", 63);
var oldIcon = tray.Icon;
tray.Icon = TrayIconFactory.Create(status.State);
oldIcon?.Dispose();
}
private void ShowMainWindow() => window.ShowAndActivate();
private async void RestartConnection()
{
if (!await controller.ReconnectAsync())
{
ShowMainWindow();
}
}
private static bool CopyDiagnostics(string text)
{
try
{
WinForms.Clipboard.SetText(text);
return true;
}
catch (ExternalException)
{
return false;
}
}
private Task RegisterFromWpfAsync(EnrollOptions options) =>
controller.RegisterAsync(options);
private Task<bool> ReconnectFromWpfAsync() => controller.ReconnectAsync();
private async Task<bool> ResetIdentityFromWpfAsync()
{
if (WinForms.MessageBox.Show(
"这会删除本机加密身份,随后需要使用新注册码重新注册。请先在管理后台删除或禁用云端旧节点。是否继续?",
"清除本机节点身份",
WinForms.MessageBoxButtons.YesNo,
WinForms.MessageBoxIcon.Warning,
WinForms.MessageBoxDefaultButton.Button2) != WinForms.DialogResult.Yes)
{
return false;
}
await controller.ResetIdentityAsync();
return true;
}
private string? ChooseSoftwareLocation(
SoftwarePathKind pathKind,
string displayName,
string requiredRelativePath)
{
if (pathKind == SoftwarePathKind.Executable)
{
using var dialog = new WinForms.OpenFileDialog
{
Title = $"选择 {displayName} 可执行文件",
Filter = $"{requiredRelativePath}|{requiredRelativePath}|可执行文件 (*.exe)|*.exe",
CheckFileExists = true,
Multiselect = false,
};
return dialog.ShowDialog() == WinForms.DialogResult.OK
? dialog.FileName
: null;
}
if (pathKind == SoftwarePathKind.Directory)
{
using var dialog = new WinForms.FolderBrowserDialog
{
Description = $"选择 {displayName} 安装根目录",
UseDescriptionForTitle = true,
ShowNewFolderButton = false,
};
return dialog.ShowDialog() == WinForms.DialogResult.OK
? dialog.SelectedPath
: null;
}
return null;
}
private static string? ChooseAcceptanceReportDirectory()
{
using var dialog = new WinForms.FolderBrowserDialog
{
Description = "选择保存 ANSYS 验收报告的父目录",
UseDescriptionForTitle = true,
ShowNewFolderButton = true,
};
return dialog.ShowDialog() == WinForms.DialogResult.OK
? dialog.SelectedPath
: null;
}
private static bool ConfirmAnsysAcceptance() => WinForms.MessageBox.Show(
"程序会使用内置标准试件执行一次进程树取消和连续 20 次真实静力求解,期间会获取并释放 ANSYS 许可证,可能耗时较长。确认开始?",
"开始 ANSYS 真机验收",
WinForms.MessageBoxButtons.YesNo,
WinForms.MessageBoxIcon.Warning,
WinForms.MessageBoxDefaultButton.Button2) == WinForms.DialogResult.Yes;
private static bool ConfirmEnableAnsysGate() => WinForms.MessageBox.Show(
"请先在许可证管理端确认正常求解和取消后的席位均已释放。开启后需要完全退出并重新启动 Node 才会进入实际调度。确认开启机器级执行门?",
"开启 ANSYS 执行门",
WinForms.MessageBoxButtons.YesNo,
WinForms.MessageBoxIcon.Warning,
WinForms.MessageBoxDefaultButton.Button2) == WinForms.DialogResult.Yes;
private static string? ChooseDataRootTarget(string currentRoot)
{
using var dialog = new WinForms.FolderBrowserDialog
{
Description = "选择新的 zcbot Windows Node 数据目录(必须为空)",
UseDescriptionForTitle = true,
SelectedPath = currentRoot,
ShowNewFolderButton = true,
};
return dialog.ShowDialog() == WinForms.DialogResult.OK
? dialog.SelectedPath
: null;
}
private Task<NodeDataMigrationResult> MigrateDataRootFromWpfAsync(string target) =>
controller.MigrateDataRootAsync(target);
private static bool ConfirmDataRootMigration(string target) =>
WinForms.MessageBox.Show(
$"Node 将先停止接收任务,把现有数据复制并校验到:\n{target}\n\n"
+ "校验成功后切换目录并重启;旧目录不会自动删除。是否继续?",
"迁移数据目录",
WinForms.MessageBoxButtons.YesNo,
WinForms.MessageBoxIcon.Warning,
WinForms.MessageBoxDefaultButton.Button2) == WinForms.DialogResult.Yes;
private static void CompleteDataRootMigration(NodeDataMigrationResult result)
{
WinForms.MessageBox.Show(
$"数据目录迁移完成:{result.FileCount} 个文件。\n"
+ $"新目录:{result.TargetDirectory}\n\n旧目录仍保留{result.SourceDirectory}\n"
+ "确认新节点运行正常后可由管理员手工清理旧目录。Node 现在将重启。",
"迁移完成",
WinForms.MessageBoxButtons.OK,
WinForms.MessageBoxIcon.Information);
WinForms.Application.Restart();
}
private static void OpenDataRoot(string path)
{
try
{
Directory.CreateDirectory(path);
var startInfo = new ProcessStartInfo
{
FileName = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.Windows),
"explorer.exe"),
UseShellExecute = false,
};
startInfo.ArgumentList.Add(path);
Process.Start(startInfo);
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException or InvalidOperationException)
{
WinForms.MessageBox.Show(
exception.Message,
"无法打开数据目录",
WinForms.MessageBoxButtons.OK,
WinForms.MessageBoxIcon.Error);
}
}
private static bool ConfirmRuntimeInstall(string displayName) =>
WinForms.MessageBox.Show(
$"将为 {displayName} 创建独立 Python 3.12 运行环境并安装固定依赖。是否继续?",
"安装专业软件运行环境",
WinForms.MessageBoxButtons.YesNo,
WinForms.MessageBoxIcon.Question,
WinForms.MessageBoxDefaultButton.Button2) == WinForms.DialogResult.Yes;
private async Task ExitNodeAsync()
{
if (exiting) return;
var activeJobs = controller.ActiveJobCount;
var cancelJobs = false;
if (activeJobs > 0)
{
var choice = WinForms.MessageBox.Show(
$"当前有 {activeJobs} 个本机任务尚未结束。\n\n"
+ "选择“是”:停止接收新任务,等待现有任务完成后退出。\n"
+ "选择“否”:取消现有任务并退出。\n"
+ "选择“取消”:返回,不退出。",
"退出 zcbot Windows Node",
WinForms.MessageBoxButtons.YesNoCancel,
WinForms.MessageBoxIcon.Warning,
WinForms.MessageBoxDefaultButton.Button1);
if (choice == WinForms.DialogResult.Cancel) return;
cancelJobs = choice == WinForms.DialogResult.No;
if (cancelJobs && WinForms.MessageBox.Show(
"取消任务会终止正在运行的专业软件进程,未完成结果不会上传。确认取消任务并退出?",
"确认取消任务",
WinForms.MessageBoxButtons.YesNo,
WinForms.MessageBoxIcon.Warning,
WinForms.MessageBoxDefaultButton.Button2) != WinForms.DialogResult.Yes)
{
return;
}
}
exiting = true;
window.ViewModel.BeginExit();
window.IsEnabled = false;
exitItem.Enabled = false;
if (tray.ContextMenuStrip is not null) tray.ContextMenuStrip.Enabled = false;
await controller.StopAsync(
cancelJobs ? NodeShutdownMode.CancelJobs : NodeShutdownMode.WaitForJobs);
tray.Visible = false;
window.AllowClose = true;
window.Close();
shutdown();
}
private static string Limit(string value, int length) =>
value.Length <= length ? value : value[..length];
private static string CompactMenuText(string value, int length)
{
var singleLine = value.Replace('\r', ' ').Replace('\n', ' ').Trim();
return singleLine.Length <= length
? singleLine
: singleLine[..(length - 1)] + "…";
}
}

View File

@ -0,0 +1,169 @@
using System.Security;
using System.Text.Json;
using System.Windows.Input;
namespace Zcbot.WindowsNode;
internal sealed class AnsysAcceptanceViewModel : ObservableObject, IDisposable
{
private readonly IAnsysAcceptanceService service;
private readonly AsyncCommand runCommand;
private readonly RelayCommand cancelCommand;
private readonly RelayCommand enableGateCommand;
private CancellationTokenSource? stop;
private string? passedReportPath;
private string message;
private string tone;
internal AnsysAcceptanceViewModel(IAnsysAcceptanceService service)
{
this.service = service;
(message, tone) = GateStatus();
runCommand = new AsyncCommand(RunAsync, () => IsAvailable && !IsRunning);
cancelCommand = new RelayCommand(Cancel, () => IsRunning);
enableGateCommand = new RelayCommand(EnableGate, () => CanEnableGate);
RunCommand = runCommand;
CancelCommand = cancelCommand;
EnableGateCommand = enableGateCommand;
}
internal event Func<string?>? ChooseReportDirectoryRequested;
internal event Func<bool>? ConfirmRunRequested;
internal event Func<bool>? ConfirmEnableGateRequested;
public bool IsAvailable => service.IsAvailable;
public bool IsRunning => stop is not null;
public bool IsGateEnabled => service.IsGateEnabled;
public bool CanEnableGate => passedReportPath is not null && !IsGateEnabled && !IsRunning;
public string Message
{
get => message;
private set => SetProperty(ref message, value);
}
public string Tone
{
get => tone;
private set => SetProperty(ref tone, value);
}
public ICommand RunCommand { get; }
public ICommand CancelCommand { get; }
public ICommand EnableGateCommand { get; }
internal void RefreshStatus()
{
if (IsRunning) return;
var status = GateStatus();
Message = status.Message;
Tone = status.Tone;
RaiseStateChanged();
}
internal void Cancel() => stop?.Cancel();
public void Dispose()
{
stop?.Cancel();
stop?.Dispose();
stop = null;
}
private async Task RunAsync()
{
if (!IsAvailable)
{
Message = "当前安装包没有 ANSYS 验收工具。请先安装新版完整 Node 包。";
Tone = "error";
return;
}
var parent = ChooseReportDirectoryRequested?.Invoke();
if (string.IsNullOrWhiteSpace(parent) || ConfirmRunRequested?.Invoke() != true)
{
return;
}
var workRoot = Path.Combine(
parent,
$"zcbot-ansys-acceptance-{DateTime.Now:yyyyMMdd-HHmmss}");
stop?.Dispose();
stop = new CancellationTokenSource();
passedReportPath = null;
Message = "正在启动真机验收…";
Tone = "warning";
RaiseStateChanged();
var progress = new Progress<string>(line => Message = line);
try
{
var result = await service.RunAsync(workRoot, progress, stop.Token);
passedReportPath = result.Passed ? result.ReportPath : null;
Tone = result.Passed ? "success" : "error";
Message = result.Passed
? $"验收通过。报告:{result.ReportPath}\n请确认许可证管理端席位已释放再开启执行门。"
: $"验收未通过:{result.Detail}\n报告{result.ReportPath}";
}
catch (OperationCanceledException)
{
passedReportPath = null;
Tone = "neutral";
Message = "验收已停止,执行门保持关闭。";
}
catch (Exception exception) when (
exception is IOException
or InvalidDataException
or InvalidOperationException
or JsonException
or UnauthorizedAccessException)
{
passedReportPath = null;
Tone = "error";
Message = $"验收启动或执行失败:{exception.Message}";
}
finally
{
stop.Dispose();
stop = null;
RaiseStateChanged();
}
}
private void EnableGate()
{
if (passedReportPath is null || ConfirmEnableGateRequested?.Invoke() != true)
{
return;
}
try
{
service.EnableGate(passedReportPath);
Tone = "success";
Message = "机器级执行门已写入。请从托盘退出 Node 后重新启动;重启后 ANSYS 应显示可用。";
}
catch (Exception exception) when (
exception is InvalidDataException
or SecurityException
or UnauthorizedAccessException)
{
Tone = "error";
Message = exception is SecurityException or UnauthorizedAccessException
? $"写入机器级环境变量需要管理员权限:{exception.Message}\n请退出 Node以管理员身份启动后再次点击。"
: exception.Message;
}
RaiseStateChanged();
}
private (string Message, string Tone) GateStatus() => IsGateEnabled
? ("当前执行门已开启。Node 重启并且 probe 正常后可接收 ANSYS 任务。", "success")
: ("当前执行门:关闭。安装成功不代表验收通过,请先运行本页验收。", "neutral");
private void RaiseStateChanged()
{
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsGateEnabled));
OnPropertyChanged(nameof(CanEnableGate));
runCommand.RaiseCanExecuteChanged();
cancelCommand.RaiseCanExecuteChanged();
enableGateCommand.RaiseCanExecuteChanged();
}
}

View File

@ -0,0 +1,132 @@
using System.Security;
using System.Windows.Input;
namespace Zcbot.WindowsNode;
internal sealed class DataRootPageViewModel : ObservableObject
{
private readonly IDataRootManagementService service;
private readonly AsyncCommand changeCommand;
private NodeDataRootSelection selection;
private string status;
private bool isMigrating;
internal DataRootPageViewModel(IDataRootManagementService service)
{
this.service = service;
selection = service.ReadSelection();
status = SelectionStatus(selection);
changeCommand = new AsyncCommand(ChangeAsync, () => !IsManaged && !IsMigrating);
ChangeCommand = changeCommand;
OpenCommand = new RelayCommand(() => OpenRequested?.Invoke(CurrentRoot));
}
internal event Func<string, string?>? ChooseTargetRequested;
internal event Func<string, bool>? ConfirmMigrationRequested;
internal event Func<string, Task<NodeDataMigrationResult>>? MigrationRequested;
internal event Action<NodeDataMigrationResult>? MigrationCompleted;
internal event Action<string>? OpenRequested;
public string CurrentRoot => selection.RootDirectory;
public bool IsManaged => selection.IsEnvironmentManaged;
public bool IsMigrating
{
get => isMigrating;
private set
{
if (SetProperty(ref isMigrating, value))
{
changeCommand.RaiseCanExecuteChanged();
}
}
}
public string Status
{
get => status;
private set => SetProperty(ref status, value);
}
public ICommand ChangeCommand { get; }
public ICommand OpenCommand { get; }
internal void Refresh()
{
selection = service.ReadSelection();
OnPropertyChanged(nameof(CurrentRoot));
OnPropertyChanged(nameof(IsManaged));
Status = SelectionStatus(selection);
changeCommand.RaiseCanExecuteChanged();
}
private async Task ChangeAsync()
{
if (MigrationRequested is null) return;
try
{
if (service.HasPendingJobs)
{
Status = "本机仍有未完成任务,暂不能迁移数据目录。请等待任务结束后重试。";
return;
}
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException)
{
Status = $"无法确认本机任务状态,数据目录不会迁移:{exception.Message}";
return;
}
var selected = ChooseTargetRequested?.Invoke(CurrentRoot);
if (string.IsNullOrWhiteSpace(selected)) return;
string target;
try
{
target = service.Normalize(selected);
}
catch (NodeConfigurationException exception)
{
Status = exception.Message;
return;
}
if (string.Equals(target, CurrentRoot, StringComparison.OrdinalIgnoreCase))
{
Status = "所选目录与当前数据目录相同。";
return;
}
if (ConfirmMigrationRequested?.Invoke(target) != true) return;
IsMigrating = true;
Status = "正在停止调度并复制、校验数据,请勿退出 Node…";
try
{
var result = await MigrationRequested(target);
Status = $"迁移完成:{result.FileCount} 个文件,{FormatBytes(result.TotalBytes)}。正在重启 Node…";
MigrationCompleted?.Invoke(result);
}
catch (Exception exception) when (
exception is IOException
or NodeConfigurationException
or SecurityException
or UnauthorizedAccessException)
{
IsMigrating = false;
Refresh();
Status = $"数据目录未切换,原目录继续有效:{exception.Message}";
}
}
private static string SelectionStatus(NodeDataRootSelection value) =>
value.IsEnvironmentManaged
? $"由机器环境变量 {NodeDataRootSettings.EnvironmentVariableName} 管理,界面不可修改。"
: value.Source == "user"
? "使用当前 Windows 专用账号保存的自定义目录。"
: "使用默认目录。";
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";
}

View File

@ -0,0 +1,120 @@
using System.Collections.ObjectModel;
using System.Windows.Input;
namespace Zcbot.WindowsNode;
internal sealed class LocalJobItemViewModel(JobDisplaySnapshot snapshot)
{
internal JobDisplaySnapshot Snapshot { get; } = snapshot;
public Guid JobId => Snapshot.JobId;
public string StageText => FormatStage(Snapshot.Stage);
public string Title => Snapshot.Title;
public string InputFilename => Snapshot.InputFilename;
public string ProgressText => Snapshot.Stage == "software_running"
? $"已执行 {FormatElapsed(DateTimeOffset.UtcNow - Snapshot.UpdatedAt)}"
: $"{Snapshot.Progress}%";
public string AcceptedAtText => Snapshot.AcceptedAt.LocalDateTime.ToString("MM-dd HH:mm");
public string ShortId => Snapshot.JobId.ToString("N")[..8];
public string Tone => Snapshot.Stage switch
{
"failed" or "cloud_terminal" => "error",
"succeeded" => "success",
"cancelled" => "neutral",
_ => "active",
};
public string DetailText => string.Join("\n", [
$"{StageText} · {Snapshot.Detail}",
$"能力:{Snapshot.Capability}",
$"Job ID{Snapshot.JobId}",
$"更新时间:{Snapshot.UpdatedAt.LocalDateTime:yyyy-MM-dd HH:mm:ss}",
]);
private static string FormatElapsed(TimeSpan elapsed)
{
if (elapsed < TimeSpan.Zero) elapsed = TimeSpan.Zero;
return elapsed.TotalHours >= 1
? $"{(int)elapsed.TotalHours}:{elapsed.Minutes:00}:{elapsed.Seconds:00}"
: $"{elapsed.Minutes:00}:{elapsed.Seconds:00}";
}
private static string FormatStage(string stage) => stage switch
{
"accepted" => "等待处理",
"downloading_inputs" => "下载输入",
"ready_to_run" => "准备执行",
"software_running" => "软件执行中",
"uploading_outputs" => "上传结果",
"succeeded" => "成功",
"failed" => "失败",
"cloud_terminal" => "云端已终止",
"cancelled" => "已取消",
_ => stage,
};
}
internal sealed class JobPageViewModel : ObservableObject
{
private readonly IJobMonitorService service;
private LocalJobItemViewModel? selectedJob;
private string summary = "正在读取本机任务…";
internal JobPageViewModel(IJobMonitorService service)
{
this.service = service;
RefreshCommand = new RelayCommand(Refresh);
}
public ObservableCollection<LocalJobItemViewModel> Jobs { get; } = [];
public LocalJobItemViewModel? SelectedJob
{
get => selectedJob;
set
{
if (SetProperty(ref selectedJob, value))
{
OnPropertyChanged(nameof(SelectedDetail));
}
}
}
public string Summary
{
get => summary;
private set => SetProperty(ref summary, value);
}
public string SelectedDetail => SelectedJob?.DetailText
?? "选择任务后可查看执行阶段、更新时间和完整 Job ID。";
public ICommand RefreshCommand { get; }
internal void Refresh()
{
var selectedId = SelectedJob?.JobId;
IReadOnlyList<JobDisplaySnapshot> snapshots;
try
{
snapshots = service.ReadSnapshots();
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException)
{
Summary = $"无法读取本机任务:{exception.Message}";
return;
}
Jobs.Clear();
foreach (var snapshot in snapshots)
{
Jobs.Add(new LocalJobItemViewModel(snapshot));
}
var active = snapshots.Count(item => item.IsActive);
Summary = snapshots.Count == 0
? "暂无本机任务"
: $"活动任务 {active} 个 · 最近记录 {snapshots.Count} 条";
SelectedJob = Jobs.FirstOrDefault(item => item.JobId == selectedId)
?? Jobs.FirstOrDefault();
}
}

View File

@ -0,0 +1,390 @@
using System.Security;
using System.Windows.Input;
namespace Zcbot.WindowsNode;
internal sealed class MainWindowViewModel : ObservableObject, IDisposable
{
private readonly IStartupRegistration startupRegistration;
private readonly IDiagnosticService diagnosticService;
private readonly AsyncCommand registerCommand;
private readonly AsyncCommand reconnectCommand;
private readonly AsyncCommand resetIdentityCommand;
private string statusText = "尚未注册";
private string statusDetail = "等待节点状态";
private string identityText = "尚未建立节点身份";
private string serverUrl = "http://127.0.0.1:8765";
private string nodeName = Environment.MachineName.ToLowerInvariant();
private string enrollmentCode = string.Empty;
private string operationMessage = string.Empty;
private string statusTone = "neutral";
private bool isRegistered;
private bool isBusy;
private bool isExiting;
private string currentPage = "overview";
private bool isStartupEnabled;
private NodeConfig? currentConfig;
internal MainWindowViewModel(
IStartupRegistration startupRegistration,
IDiagnosticService diagnosticService,
SoftwarePageViewModel softwarePage,
JobPageViewModel jobPage,
AnsysAcceptanceViewModel acceptancePage,
DataRootPageViewModel dataRootPage)
{
this.startupRegistration = startupRegistration;
this.diagnosticService = diagnosticService;
SoftwarePage = softwarePage;
JobPage = jobPage;
AcceptancePage = acceptancePage;
DataRootPage = dataRootPage;
try
{
isStartupEnabled = startupRegistration.IsEnabled;
}
catch (Exception exception) when (
exception is UnauthorizedAccessException or IOException or SecurityException)
{
operationMessage = $"无法读取自动启动设置:{exception.Message}";
}
CopyDiagnosticsCommand = new RelayCommand(CopyDiagnostics, () => IsRegistered);
HideCommand = new RelayCommand(() => HideRequested?.Invoke(), () => !IsExiting);
ExitCommand = new AsyncCommand(
() => ExitRequested?.Invoke() ?? Task.CompletedTask,
() => !IsExiting);
ShowOverviewCommand = new RelayCommand(() => Navigate("overview"));
ShowSoftwareCommand = new AsyncCommand(async () =>
{
Navigate("software");
await SoftwarePage.RefreshAsync();
AcceptancePage.RefreshStatus();
});
ShowJobsCommand = new RelayCommand(() =>
{
Navigate("jobs");
JobPage.Refresh();
});
ShowSettingsCommand = new RelayCommand(() =>
{
Navigate("settings");
DataRootPage.Refresh();
});
registerCommand = new AsyncCommand(RegisterAsync, CanRegister);
reconnectCommand = new AsyncCommand(ReconnectAsync, () => IsRegistered && !IsBusy);
resetIdentityCommand = new AsyncCommand(ResetIdentityAsync, () => IsRegistered && !IsBusy);
RegisterCommand = registerCommand;
ReconnectCommand = reconnectCommand;
ResetIdentityCommand = resetIdentityCommand;
}
internal event Func<string, bool>? CopyDiagnosticsRequested;
internal event Action? HideRequested;
internal event Func<Task>? ExitRequested;
internal event Func<EnrollOptions, Task>? RegisterRequested;
internal event Func<Task<bool>>? ReconnectRequested;
internal event Func<Task<bool>>? ResetIdentityRequested;
public string StatusText
{
get => statusText;
private set => SetProperty(ref statusText, value);
}
public string StatusDetail
{
get => statusDetail;
private set => SetProperty(ref statusDetail, value);
}
public string IdentityText
{
get => identityText;
private set => SetProperty(ref identityText, value);
}
public string ServerUrl
{
get => serverUrl;
set
{
if (SetProperty(ref serverUrl, value)) registerCommand.RaiseCanExecuteChanged();
}
}
public string NodeName
{
get => nodeName;
set
{
if (SetProperty(ref nodeName, value)) registerCommand.RaiseCanExecuteChanged();
}
}
public string EnrollmentCode
{
get => enrollmentCode;
set
{
if (SetProperty(ref enrollmentCode, value)) registerCommand.RaiseCanExecuteChanged();
}
}
public string OperationMessage
{
get => operationMessage;
private set
{
if (SetProperty(ref operationMessage, value))
{
OnPropertyChanged(nameof(HasOperationMessage));
}
}
}
public bool HasOperationMessage => !string.IsNullOrWhiteSpace(OperationMessage);
public string StatusTone
{
get => statusTone;
private set => SetProperty(ref statusTone, value);
}
public bool IsRegistered
{
get => isRegistered;
private set
{
if (SetProperty(ref isRegistered, value))
{
OnPropertyChanged(nameof(IsRegistrationVisible));
RaiseOperationCanExecuteChanged();
((RelayCommand)CopyDiagnosticsCommand).RaiseCanExecuteChanged();
}
}
}
public bool IsRegistrationVisible => !IsRegistered;
public bool IsBusy
{
get => isBusy;
private set
{
if (SetProperty(ref isBusy, value)) RaiseOperationCanExecuteChanged();
}
}
public bool IsExiting
{
get => isExiting;
private set
{
if (SetProperty(ref isExiting, value))
{
((RelayCommand)HideCommand).RaiseCanExecuteChanged();
((AsyncCommand)ExitCommand).RaiseCanExecuteChanged();
}
}
}
public bool IsOverviewPage => currentPage == "overview";
public bool IsSoftwarePage => currentPage == "software";
public bool IsJobsPage => currentPage == "jobs";
public bool IsSettingsPage => currentPage == "settings";
public string PageTitle => currentPage switch
{
"software" => "专业软件",
"jobs" => "本机任务",
"settings" => "运行设置",
_ => "节点概览",
};
public bool IsStartupEnabled
{
get => isStartupEnabled;
set
{
if (value == isStartupEnabled) return;
try
{
startupRegistration.SetEnabled(value);
SetProperty(ref isStartupEnabled, value);
OperationMessage = value
? "已启用登录 Windows 后自动启动"
: "已关闭登录 Windows 后自动启动";
}
catch (Exception exception) when (
exception is UnauthorizedAccessException or IOException or SecurityException)
{
OperationMessage = $"自动启动设置失败:{exception.Message}";
OnPropertyChanged();
}
}
}
public ICommand CopyDiagnosticsCommand { get; }
public ICommand HideCommand { get; }
public ICommand ExitCommand { get; }
public ICommand ShowOverviewCommand { get; }
public ICommand ShowSoftwareCommand { get; }
public ICommand ShowJobsCommand { get; }
public ICommand ShowSettingsCommand { get; }
public ICommand RegisterCommand { get; }
public ICommand ReconnectCommand { get; }
public ICommand ResetIdentityCommand { get; }
public SoftwarePageViewModel SoftwarePage { get; }
public JobPageViewModel JobPage { get; }
public AnsysAcceptanceViewModel AcceptancePage { get; }
public DataRootPageViewModel DataRootPage { get; }
internal void ApplyStatus(NodeStatus status, NodeConfig? config = null)
{
StatusText = status.State switch
{
NodeState.Online => "节点在线",
NodeState.Connecting => "正在连接",
NodeState.AuthenticationRequired => "需要重新注册",
NodeState.Offline => "节点离线",
NodeState.Stopped => "节点已停止",
_ => "尚未注册",
};
StatusTone = status.State switch
{
NodeState.Online => "success",
NodeState.Connecting => "warning",
NodeState.NotRegistered or NodeState.AuthenticationRequired => "error",
_ => "neutral",
};
StatusDetail = $"{status.Message} {status.ChangedAt:HH:mm:ss}";
IsRegistered = config is not null;
currentConfig = config;
IdentityText = config is null
? "尚未建立节点身份"
: $"节点:{config.NodeName}\nNode ID{config.NodeId}\n服务{config.ServerUrl}";
if (config is not null)
{
ServerUrl = config.ServerUrl.AbsoluteUri.TrimEnd('/');
NodeName = config.NodeName;
EnrollmentCode = string.Empty;
}
}
internal void BeginExit()
{
SoftwarePage.CancelInstall();
IsExiting = true;
StatusText = "正在退出";
StatusTone = "neutral";
StatusDetail = "正在等待本机任务与后台进程安全收尾";
}
public void Dispose()
{
SoftwarePage.Dispose();
AcceptancePage.Dispose();
}
private bool CanRegister() =>
!IsRegistered
&& !IsBusy
&& !string.IsNullOrWhiteSpace(ServerUrl)
&& !string.IsNullOrWhiteSpace(NodeName)
&& !string.IsNullOrWhiteSpace(EnrollmentCode);
private void CopyDiagnostics()
{
if (currentConfig is null) return;
OperationMessage = CopyDiagnosticsRequested?.Invoke(
diagnosticService.Build(currentConfig)) == true
? "诊断信息已复制;内容不包含 Node Token。"
: "无法写入剪贴板,请稍后重试。";
}
private async Task RegisterAsync()
{
if (RegisterRequested is null) return;
await RunOperationAsync(async () =>
{
var options = EnrollOptions.Parse([
"--server", ServerUrl,
"--name", NodeName,
"--code", EnrollmentCode]);
await RegisterRequested(options);
OperationMessage = "注册成功,正在连接节点";
}, "正在注册节点…");
}
private async Task ReconnectAsync()
{
if (ReconnectRequested is null) return;
await RunOperationAsync(async () =>
{
OperationMessage = await ReconnectRequested()
? "已开始重新连接"
: "本机尚未注册";
}, "正在等待任务收尾并重新连接…");
}
private async Task ResetIdentityAsync()
{
if (ResetIdentityRequested is null) return;
await RunOperationAsync(async () =>
{
if (await ResetIdentityRequested())
{
OperationMessage = "本机身份已清除,请使用新注册码注册";
}
else
{
OperationMessage = "已取消清除本机身份";
}
}, "正在清除本机身份…");
}
private async Task RunOperationAsync(Func<Task> operation, string progress)
{
IsBusy = true;
OperationMessage = progress;
try
{
await operation();
}
catch (Exception exception) when (
exception is NodeConfigurationException
or HttpRequestException
or IOException
or UnauthorizedAccessException)
{
OperationMessage = exception.Message;
}
finally
{
IsBusy = false;
}
}
private void RaiseOperationCanExecuteChanged()
{
registerCommand.RaiseCanExecuteChanged();
reconnectCommand.RaiseCanExecuteChanged();
resetIdentityCommand.RaiseCanExecuteChanged();
}
private void Navigate(string page)
{
if (currentPage == page) return;
currentPage = page;
OnPropertyChanged(nameof(IsOverviewPage));
OnPropertyChanged(nameof(IsSoftwarePage));
OnPropertyChanged(nameof(IsJobsPage));
OnPropertyChanged(nameof(IsSettingsPage));
OnPropertyChanged(nameof(PageTitle));
}
}

View File

@ -0,0 +1,274 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Security;
using System.Windows.Input;
namespace Zcbot.WindowsNode;
internal sealed class SoftwareCardViewModel : ObservableObject
{
private SoftwareManagementSnapshot snapshot;
private string locationText;
private string statusText;
internal SoftwareCardViewModel(
SoftwareManagementSnapshot snapshot,
Func<SoftwareCardViewModel, Task> chooseLocation,
Func<SoftwareCardViewModel, Task> clearLocation,
Func<SoftwareCardViewModel, Task> installRuntime)
{
this.snapshot = snapshot;
locationText = snapshot.LocationText;
statusText = snapshot.StatusText;
ChooseLocationCommand = new AsyncCommand(() => chooseLocation(this), CanChange);
ClearLocationCommand = new AsyncCommand(() => clearLocation(this), CanChange);
InstallRuntimeCommand = new AsyncCommand(() => installRuntime(this), CanInstall);
}
public string Id => snapshot.Id;
public string DisplayName => snapshot.DisplayName;
internal SoftwarePathKind PathKind => snapshot.PathKind;
internal string RequiredRelativePath => snapshot.RequiredRelativePath;
public string LocationText
{
get => locationText;
private set => SetProperty(ref locationText, value);
}
public string StatusText
{
get => statusText;
set => SetProperty(ref statusText, value);
}
public bool LocationAvailable => snapshot.LocationAvailable;
public bool RuntimeInstalled => snapshot.RuntimeInstalled;
public bool HasActiveJobs => snapshot.HasActiveJobs;
internal bool IsPageBusy { get; set; }
public ICommand ChooseLocationCommand { get; }
public ICommand ClearLocationCommand { get; }
public ICommand InstallRuntimeCommand { get; }
internal void Apply(SoftwareManagementSnapshot value)
{
snapshot = value;
LocationText = value.LocationText;
StatusText = value.StatusText;
OnPropertyChanged(nameof(LocationAvailable));
OnPropertyChanged(nameof(RuntimeInstalled));
OnPropertyChanged(nameof(HasActiveJobs));
RaiseCanExecuteChanged();
}
internal void RaiseCanExecuteChanged()
{
((AsyncCommand)ChooseLocationCommand).RaiseCanExecuteChanged();
((AsyncCommand)ClearLocationCommand).RaiseCanExecuteChanged();
((AsyncCommand)InstallRuntimeCommand).RaiseCanExecuteChanged();
}
private bool CanChange() => !IsPageBusy;
private bool CanInstall() => !IsPageBusy && LocationAvailable && !HasActiveJobs;
}
internal sealed class SoftwarePageViewModel : ObservableObject, IDisposable
{
private readonly ISoftwareManagementService service;
private CancellationTokenSource? installStop;
private bool isBusy;
private string message = "打开页面后检测本机软件与运行环境。";
internal SoftwarePageViewModel(ISoftwareManagementService service)
{
this.service = service;
RefreshCommand = new AsyncCommand(RefreshAsync, () => !IsBusy);
CancelInstallCommand = new RelayCommand(CancelInstall, () => installStop is not null);
}
internal event Func<SoftwarePathKind, string, string, string?>? ChooseLocationRequested;
internal event Func<string, bool>? ConfirmInstallRequested;
public ObservableCollection<SoftwareCardViewModel> Cards { get; } = [];
public bool IsBusy
{
get => isBusy;
private set
{
if (!SetProperty(ref isBusy, value)) return;
foreach (var card in Cards)
{
card.IsPageBusy = value;
card.RaiseCanExecuteChanged();
}
((AsyncCommand)RefreshCommand).RaiseCanExecuteChanged();
((RelayCommand)CancelInstallCommand).RaiseCanExecuteChanged();
}
}
public bool IsInstalling => installStop is not null;
public string Message
{
get => message;
private set => SetProperty(ref message, value);
}
public ICommand RefreshCommand { get; }
public ICommand CancelInstallCommand { get; }
internal async Task RefreshAsync()
{
if (IsBusy) return;
IsBusy = true;
Message = "正在检测本机软件与运行环境…";
try
{
await RefreshCoreAsync(CancellationToken.None);
Message = "检测完成";
}
catch (Exception exception) when (
exception is IOException
or InvalidDataException
or InvalidOperationException
or SecurityException
or UnauthorizedAccessException
or Win32Exception)
{
Message = $"检测失败:{exception.Message}";
}
finally
{
IsBusy = false;
}
}
internal void CancelInstall() => installStop?.Cancel();
public void Dispose()
{
installStop?.Cancel();
installStop?.Dispose();
installStop = null;
}
private async Task ChooseLocationAsync(SoftwareCardViewModel card)
{
var selected = ChooseLocationRequested?.Invoke(
card.PathKind,
card.DisplayName,
card.RequiredRelativePath);
if (string.IsNullOrWhiteSpace(selected)) return;
await ChangeLocationAsync(
() => service.SaveLocation(card.Id, selected),
"应用位置已保存");
}
private Task ClearLocationAsync(SoftwareCardViewModel card) =>
ChangeLocationAsync(
() => service.ClearLocation(card.Id),
"已恢复自动检测");
private async Task ChangeLocationAsync(Action change, string successMessage)
{
IsBusy = true;
try
{
change();
await RefreshCoreAsync(CancellationToken.None);
Message = successMessage;
}
catch (Exception exception) when (
exception is ArgumentException
or IOException
or InvalidDataException
or InvalidOperationException
or SecurityException
or UnauthorizedAccessException)
{
Message = $"应用位置无效:{exception.Message}";
}
finally
{
IsBusy = false;
}
}
private async Task InstallRuntimeAsync(SoftwareCardViewModel card)
{
if (card.HasActiveJobs)
{
Message = $"{card.DisplayName} 仍有任务在执行,暂不能更换运行环境。";
return;
}
if (ConfirmInstallRequested?.Invoke(card.DisplayName) != true) return;
installStop?.Dispose();
installStop = new CancellationTokenSource();
OnPropertyChanged(nameof(IsInstalling));
IsBusy = true;
Message = $"正在安装 {card.DisplayName} 运行环境…";
var progress = new Progress<string>(line =>
{
card.StatusText = line;
Message = line;
});
try
{
var result = await service.InstallRuntimeAsync(
card.Id,
progress,
installStop.Token);
await RefreshCoreAsync(CancellationToken.None);
Message = $"{result.Detail} {result.RuntimePath}";
}
catch (OperationCanceledException)
{
Message = "安装已取消,原运行环境保持不变。";
await RefreshCoreAsync(CancellationToken.None);
}
catch (Exception exception) when (
exception is IOException
or InvalidDataException
or InvalidOperationException
or SecurityException
or UnauthorizedAccessException
or Win32Exception)
{
Message = $"安装失败:{exception.Message}";
}
finally
{
installStop.Dispose();
installStop = null;
OnPropertyChanged(nameof(IsInstalling));
IsBusy = false;
}
}
private async Task RefreshCoreAsync(CancellationToken cancellationToken)
{
var snapshots = await service.RefreshAsync(cancellationToken);
foreach (var snapshot in snapshots)
{
var card = Cards.SingleOrDefault(item => item.Id == snapshot.Id);
if (card is null)
{
card = new SoftwareCardViewModel(
snapshot,
ChooseLocationAsync,
ClearLocationAsync,
InstallRuntimeAsync);
card.IsPageBusy = IsBusy;
Cards.Add(card);
}
else
{
card.Apply(snapshot);
}
}
}
}

View File

@ -27,9 +27,9 @@ internal static class Program
try
{
var store = new NodeConfigStore(NodePaths.ForCurrentMachine());
Application.Run(new TrayApplicationContext(store));
return 0;
var compositionRoot = new NodeCompositionRoot(NodePaths.ForCurrentMachine());
var app = new App(compositionRoot.CreateApplicationController());
return app.Run();
}
catch (NodeConfigurationException exception)
{
@ -46,11 +46,12 @@ internal static class Program
{
try
{
var store = new NodeConfigStore(NodePaths.ForCurrentMachine());
var compositionRoot = new NodeCompositionRoot(NodePaths.ForCurrentMachine());
if (string.Equals(args[0], "enroll", StringComparison.OrdinalIgnoreCase))
{
var options = EnrollOptions.Parse(args[1..]);
await EnrollmentClient.EnrollAsync(options, store, CancellationToken.None);
await EnrollmentClient.EnrollAsync(
options, compositionRoot.ConfigStore, CancellationToken.None);
Console.WriteLine("[OK] Node registration saved.");
return 0;
}
@ -67,8 +68,7 @@ internal static class Program
eventArgs.Cancel = true;
shutdown.Cancel();
};
var config = store.Load();
await new NodeConnectionLoop(config).RunAsync(shutdown.Token);
await compositionRoot.RunHeadlessAsync(shutdown.Token);
return 0;
}
catch (OperationCanceledException)

View File

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Zcbot.WindowsNode.Tests")]

View File

@ -0,0 +1,128 @@
using System.Net;
using System.Net.WebSockets;
using System.Text.Json;
namespace Zcbot.WindowsNode;
internal sealed class NodeProtocolClient : IAsyncDisposable
{
private const int MaximumMessageBytes = 1024 * 1024;
private readonly WebSocket socket;
private readonly SemaphoreSlim sendLock = new(1, 1);
internal NodeProtocolClient(WebSocket socket)
{
this.socket = socket;
}
internal bool IsOpen => socket.State == WebSocketState.Open;
internal static async Task<NodeProtocolClient> ConnectAsync(
NodeConfig config,
CancellationToken cancellationToken)
{
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
{
await socket.ConnectAsync(endpoint, cancellationToken);
return new NodeProtocolClient(socket);
}
catch (WebSocketException exception) when (
socket.HttpStatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
socket.Dispose();
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);
}
catch
{
socket.Dispose();
throw;
}
}
internal async Task SendAsync(
string type,
object payload,
CancellationToken cancellationToken)
{
var envelope = NodeProtocolCodec.Encode(type, payload);
await sendLock.WaitAsync(cancellationToken);
try
{
await socket.SendAsync(
new ArraySegment<byte>(envelope),
WebSocketMessageType.Text,
endOfMessage: true,
cancellationToken);
}
finally
{
sendLock.Release();
}
}
internal async Task<NodeProtocolMessage?> ReceiveAsync(CancellationToken cancellationToken)
{
var buffer = new byte[16 * 1024];
using var message = new MemoryStream();
while (socket.State == WebSocketState.Open)
{
var result = await socket.ReceiveAsync(
new ArraySegment<byte>(buffer), cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
ThrowIfRejected(result);
return null;
}
if (result.MessageType != WebSocketMessageType.Text)
{
throw new JsonException("Only text WebSocket messages are supported.");
}
message.Write(buffer, 0, result.Count);
if (message.Length > MaximumMessageBytes)
{
throw new JsonException("Server message exceeded 1 MiB.");
}
if (result.EndOfMessage)
{
return NodeProtocolCodec.Decode(message.ToArray());
}
}
return null;
}
public ValueTask DisposeAsync()
{
socket.Dispose();
sendLock.Dispose();
return ValueTask.CompletedTask;
}
private static void ThrowIfRejected(WebSocketReceiveResult result)
{
if ((int?)result.CloseStatus == 4003)
{
throw new NodeConfigurationException(
"节点身份已被服务端拒绝。请在管理后台确认该 Node ID 未被禁用或删除;"
+ "若记录不存在或身份已撤销,请清除本机身份并使用新注册码重新注册。"
+ $" 服务端信息:{result.CloseStatusDescription}");
}
if (result.CloseStatus == WebSocketCloseStatus.PolicyViolation)
{
throw new NodeConfigurationException(
$"节点上报被服务端拒绝:{result.CloseStatusDescription}");
}
}
}

View File

@ -0,0 +1,34 @@
using System.Text.Json;
namespace Zcbot.WindowsNode;
internal sealed record NodeProtocolMessage(string Type, JsonElement Payload);
internal static class NodeProtocolCodec
{
internal static byte[] Encode(string type, object payload) =>
JsonSerializer.SerializeToUtf8Bytes(new
{
protocol_version = 1,
message_id = Guid.NewGuid(),
type,
sent_at = DateTimeOffset.UtcNow,
payload,
});
internal static NodeProtocolMessage? Decode(byte[] message)
{
using var document = JsonDocument.Parse(message);
if (!document.RootElement.TryGetProperty("type", out var typeValue)
|| typeValue.ValueKind != JsonValueKind.String
|| typeValue.GetString() is not string type)
{
return null;
}
var payload = document.RootElement.TryGetProperty("payload", out var payloadValue)
? payloadValue.Clone()
: default;
return new NodeProtocolMessage(type, payload);
}
}

View File

@ -0,0 +1,215 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Text;
namespace Zcbot.WindowsNode;
internal static class ManagedRuntimeInstaller
{
private const string DefaultIndexUrl = "https://pypi.tuna.tsinghua.edu.cn/simple/";
private static readonly ProcessSupervisor ProcessSupervisor = new();
internal static async Task<RuntimeInstallResult> InstallAsync(
SoftwareDefinition definition,
IProgress<string> progress,
CancellationToken cancellationToken)
{
if (AdapterCatalog.HasActiveExecution)
{
throw new InvalidOperationException(
$"{definition.DisplayName} 仍有任务在执行,暂不能更换运行环境。");
}
var location = SoftwareLocationService.ResolveLocation(definition);
if (!location.Available)
{
throw new InvalidOperationException(
$"尚未检测到 {definition.DisplayName}{location.Detail}");
}
var requirements = ResolveBundledFile(definition.RequirementsRelativePath);
var root = NodePaths.ForCurrentMachine().RootDirectory;
var runtimes = Path.Combine(root, "runtimes");
Directory.CreateDirectory(runtimes);
NodeConfigStore.RestrictDirectory(root);
var runtime = Path.Combine(runtimes, definition.RuntimeId);
var staging = Path.Combine(
runtimes,
$".{definition.RuntimeId}.installing-{Guid.NewGuid():N}");
var backup = Path.Combine(
runtimes,
$".{definition.RuntimeId}.rollback-{Guid.NewGuid():N}");
var stagingPython = Path.Combine(staging, "Scripts", "python.exe");
var movedExisting = false;
try
{
progress.Report("正在查找 Python 3.12…");
var bootstrap = await FindPython312Async(cancellationToken);
progress.Report("正在创建隔离运行环境…");
await RunAsync(
bootstrap.FileName,
bootstrap.PrefixArguments.Concat(["-m", "venv", staging]),
AppContext.BaseDirectory,
progress,
cancellationToken);
if (!File.Exists(stagingPython))
{
throw new InvalidDataException("Python venv 未生成 Scripts\\python.exe。");
}
await VerifyPython312Async(stagingPython, cancellationToken);
progress.Report("正在安装 adapter 依赖…");
var indexUrl = Environment.GetEnvironmentVariable("ZCBOT_PIP_INDEX_URL");
if (string.IsNullOrWhiteSpace(indexUrl))
{
indexUrl = DefaultIndexUrl;
}
await RunAsync(
stagingPython,
["-m", "pip", "install", "--index-url", indexUrl, "--requirement", requirements],
AppContext.BaseDirectory,
progress,
cancellationToken);
if (Directory.Exists(runtime))
{
Directory.Move(runtime, backup);
movedExisting = true;
}
Directory.Move(staging, runtime);
if (movedExisting)
{
Directory.Delete(backup, recursive: true);
}
NodeConfigStore.RestrictDirectory(runtime);
return new RuntimeInstallResult(
Path.Combine(runtime, "Scripts", "python.exe"),
$"{definition.DisplayName} 运行环境安装完成。");
}
catch
{
if (movedExisting && !Directory.Exists(runtime) && Directory.Exists(backup))
{
Directory.Move(backup, runtime);
}
throw;
}
finally
{
DeleteDirectoryIfPresent(staging);
DeleteDirectoryIfPresent(backup);
}
}
private static string ResolveBundledFile(string relativePath)
{
var root = Path.GetFullPath(AppContext.BaseDirectory);
var path = Path.GetFullPath(Path.Combine(root, relativePath));
if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase) || !File.Exists(path))
{
throw new InvalidDataException("Adapter requirements 文件缺失或路径非法。");
}
return path;
}
private static async Task<BootstrapPython> FindPython312Async(
CancellationToken cancellationToken)
{
var candidates = new[]
{
new BootstrapPython("py.exe", ["-3.12"]),
new BootstrapPython("python.exe", []),
};
foreach (var candidate in candidates)
{
try
{
await RunAsync(
candidate.FileName,
candidate.PrefixArguments.Concat(
["-c", "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"]),
AppContext.BaseDirectory,
progress: null,
cancellationToken);
return candidate;
}
catch (Exception exception) when (
exception is InvalidOperationException or Win32Exception)
{
// Try the next fixed Python 3.12 discovery method.
}
}
throw new InvalidOperationException(
"未找到 Python 3.12。请先安装 Python 3.12 并启用 py launcher 或加入 PATH。");
}
private static Task VerifyPython312Async(string python, CancellationToken cancellationToken) =>
RunAsync(
python,
["-c", "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"],
AppContext.BaseDirectory,
progress: null,
cancellationToken);
private static async Task RunAsync(
string filename,
IEnumerable<string> arguments,
string workingDirectory,
IProgress<string>? progress,
CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = filename,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
foreach (var argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
startInfo.Environment["PYTHONUTF8"] = "1";
startInfo.Environment["PYTHONIOENCODING"] = "utf-8";
using var process = ProcessSupervisor.Start(
startInfo, "运行环境安装进程未启动。");
var stdout = ProcessSupervisor.CaptureOutputAsync(process.StandardOutput, progress);
var stderr = ProcessSupervisor.CaptureOutputAsync(process.StandardError, progress);
try
{
await process.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
await ProcessSupervisor.TerminateTreeAsync(process);
throw;
}
await Task.WhenAll(stdout, stderr);
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"运行环境安装进程失败,退出码 {process.ExitCode}。");
}
}
private static void DeleteDirectoryIfPresent(string path)
{
try
{
if (Directory.Exists(path)) Directory.Delete(path, recursive: true);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// A successful runtime swap must not be reported as failed only because
// antivirus software still holds a handle to the disposable directory.
}
}
private sealed record BootstrapPython(
string FileName,
IReadOnlyList<string> PrefixArguments);
}

View File

@ -0,0 +1,35 @@
namespace Zcbot.WindowsNode;
internal static class SoftwareCatalog
{
internal static IReadOnlyList<SoftwareDefinition> Definitions { get; } =
[
new(
"origin",
"Origin",
"origin",
SoftwarePathKind.Executable,
"ZCBOT_ORIGIN_EXE",
"Origin*.exe",
@"adapters\origin.plot@v2\requirements.txt"),
new(
"ansys",
"ANSYS Mechanical 2024 R2",
"ansys",
SoftwarePathKind.Directory,
"AWP_ROOT242",
@"aisol\bin\winx64\AnsysWBU.exe",
@"adapters\ansys.mechanical.static_structural@v2\requirements.txt"),
new(
"blender",
"Blender",
"blender",
SoftwarePathKind.Executable,
"ZCBOT_BLENDER_EXE",
"blender.exe",
@"adapters\blender.scene.author@v3\requirements.txt"),
];
internal static SoftwareDefinition ByRuntimeId(string runtimeId) =>
Definitions.Single(item => item.RuntimeId.Equals(runtimeId, StringComparison.Ordinal));
}

View File

@ -1,71 +1,13 @@
using Microsoft.Win32;
using System.ComponentModel;
using System.Diagnostics;
using System.Security;
using System.Text;
using System.Text.RegularExpressions;
namespace Zcbot.WindowsNode;
internal enum SoftwarePathKind
{
None,
Executable,
Directory,
}
internal sealed record SoftwareDefinition(
string Id,
string DisplayName,
string RuntimeId,
SoftwarePathKind PathKind,
string? EnvironmentVariable,
string? RequiredRelativePath,
string RequirementsRelativePath);
internal sealed record SoftwareLocation(
bool Available,
string? Path,
string Source,
string Detail);
internal sealed record RuntimeInstallResult(string RuntimePath, string Detail);
internal static class SoftwareRuntimeManager
internal static class SoftwareLocationService
{
private const string SettingsKey = @"Software\Zcbot\WindowsNode\Software";
private const string DefaultIndexUrl = "https://pypi.tuna.tsinghua.edu.cn/simple/";
internal static IReadOnlyList<SoftwareDefinition> Definitions { get; } =
[
new(
"origin",
"Origin",
"origin",
SoftwarePathKind.Executable,
"ZCBOT_ORIGIN_EXE",
"Origin*.exe",
@"adapters\origin.plot@v2\requirements.txt"),
new(
"ansys",
"ANSYS Mechanical 2024 R2",
"ansys",
SoftwarePathKind.Directory,
"AWP_ROOT242",
@"aisol\bin\winx64\AnsysWBU.exe",
@"adapters\ansys.mechanical.static_structural@v2\requirements.txt"),
new(
"blender",
"Blender",
"blender",
SoftwarePathKind.Executable,
"ZCBOT_BLENDER_EXE",
"blender.exe",
@"adapters\blender.scene.author@v3\requirements.txt"),
];
internal static SoftwareDefinition ByRuntimeId(string runtimeId) =>
Definitions.Single(item => item.RuntimeId.Equals(runtimeId, StringComparison.Ordinal));
internal static SoftwareLocation ResolveLocation(SoftwareDefinition definition)
{
@ -121,7 +63,7 @@ internal static class SoftwareRuntimeManager
{
return;
}
var definition = Definitions.SingleOrDefault(
var definition = SoftwareCatalog.Definitions.SingleOrDefault(
item => item.RuntimeId.Equals(runtimeId, StringComparison.Ordinal));
if (definition?.EnvironmentVariable is null)
{
@ -134,93 +76,6 @@ internal static class SoftwareRuntimeManager
}
}
internal static async Task<RuntimeInstallResult> InstallRuntimeAsync(
SoftwareDefinition definition,
IProgress<string> progress,
CancellationToken cancellationToken)
{
var location = ResolveLocation(definition);
if (!location.Available)
{
throw new InvalidOperationException(
$"尚未检测到 {definition.DisplayName}{location.Detail}");
}
var requirements = ResolveBundledFile(definition.RequirementsRelativePath);
var root = NodePaths.ForCurrentMachine().RootDirectory;
var runtimes = Path.Combine(root, "runtimes");
Directory.CreateDirectory(runtimes);
NodeConfigStore.RestrictDirectory(root);
var runtime = Path.Combine(runtimes, definition.RuntimeId);
var staging = Path.Combine(
runtimes,
$".{definition.RuntimeId}.installing-{Guid.NewGuid():N}");
var backup = Path.Combine(
runtimes,
$".{definition.RuntimeId}.rollback-{Guid.NewGuid():N}");
var stagingPython = Path.Combine(staging, "Scripts", "python.exe");
var movedExisting = false;
try
{
progress.Report("正在查找 Python 3.12…");
var bootstrap = await FindPython312Async(cancellationToken);
progress.Report("正在创建隔离运行环境…");
await RunAsync(
bootstrap.FileName,
bootstrap.PrefixArguments.Concat(["-m", "venv", staging]),
AppContext.BaseDirectory,
progress,
cancellationToken);
if (!File.Exists(stagingPython))
{
throw new InvalidDataException("Python venv 未生成 Scripts\\python.exe。");
}
await VerifyPython312Async(stagingPython, cancellationToken);
progress.Report("正在安装 adapter 依赖…");
var indexUrl = Environment.GetEnvironmentVariable("ZCBOT_PIP_INDEX_URL");
if (string.IsNullOrWhiteSpace(indexUrl))
{
indexUrl = DefaultIndexUrl;
}
await RunAsync(
stagingPython,
["-m", "pip", "install", "--index-url", indexUrl, "--requirement", requirements],
AppContext.BaseDirectory,
progress,
cancellationToken);
if (Directory.Exists(runtime))
{
Directory.Move(runtime, backup);
movedExisting = true;
}
Directory.Move(staging, runtime);
if (movedExisting)
{
Directory.Delete(backup, recursive: true);
}
NodeConfigStore.RestrictDirectory(runtime);
return new RuntimeInstallResult(
Path.Combine(runtime, "Scripts", "python.exe"),
$"{definition.DisplayName} 运行环境安装完成。");
}
catch
{
if (movedExisting && !Directory.Exists(runtime) && Directory.Exists(backup))
{
Directory.Move(backup, runtime);
}
throw;
}
finally
{
DeleteDirectoryIfPresent(staging);
DeleteDirectoryIfPresent(backup);
}
}
private static SoftwareLocation DetectOrigin(SoftwareDefinition definition)
{
var candidates = new List<(string Path, string Source)>();
@ -472,123 +327,4 @@ internal static class SoftwareRuntimeManager
return File.Exists(candidate) ? Path.GetFullPath(candidate) : null;
}
private static string ResolveBundledFile(string relativePath)
{
var root = Path.GetFullPath(AppContext.BaseDirectory);
var path = Path.GetFullPath(Path.Combine(root, relativePath));
if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase) || !File.Exists(path))
{
throw new InvalidDataException("Adapter requirements 文件缺失或路径非法。");
}
return path;
}
private static async Task<BootstrapPython> FindPython312Async(
CancellationToken cancellationToken)
{
var candidates = new[]
{
new BootstrapPython("py.exe", ["-3.12"]),
new BootstrapPython("python.exe", []),
};
foreach (var candidate in candidates)
{
try
{
await RunAsync(
candidate.FileName,
candidate.PrefixArguments.Concat(
["-c", "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"]),
AppContext.BaseDirectory,
progress: null,
cancellationToken);
return candidate;
}
catch (Exception exception) when (
exception is InvalidOperationException or Win32Exception)
{
// Try the next fixed Python 3.12 discovery method.
}
}
throw new InvalidOperationException(
"未找到 Python 3.12。请先安装 Python 3.12 并启用 py launcher 或加入 PATH。");
}
private static Task VerifyPython312Async(string python, CancellationToken cancellationToken) =>
RunAsync(
python,
["-c", "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"],
AppContext.BaseDirectory,
progress: null,
cancellationToken);
private static async Task RunAsync(
string filename,
IEnumerable<string> arguments,
string workingDirectory,
IProgress<string>? progress,
CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = filename,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
foreach (var argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
startInfo.Environment["PYTHONUTF8"] = "1";
startInfo.Environment["PYTHONIOENCODING"] = "utf-8";
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException("运行环境安装进程未启动。");
var stdout = RelayAsync(process.StandardOutput, progress);
var stderr = RelayAsync(process.StandardError, progress);
try
{
await process.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
throw;
}
await Task.WhenAll(stdout, stderr);
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"运行环境安装进程失败,退出码 {process.ExitCode}。");
}
}
private static async Task RelayAsync(StreamReader reader, IProgress<string>? progress)
{
while (await reader.ReadLineAsync() is { } line)
{
if (!string.IsNullOrWhiteSpace(line)) progress?.Report(line);
}
}
private static void DeleteDirectoryIfPresent(string path)
{
try
{
if (Directory.Exists(path)) Directory.Delete(path, recursive: true);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// A successful runtime swap must not be reported as failed only because
// antivirus software still holds a handle to the disposable directory.
}
}
private sealed record BootstrapPython(
string FileName,
IReadOnlyList<string> PrefixArguments);
}

View File

@ -0,0 +1,42 @@
namespace Zcbot.WindowsNode;
internal sealed class SoftwareProbeService
{
private readonly Func<AdapterRuntimeStatus> probe;
private readonly object sync = new();
private AdapterRuntimeStatus? cachedRuntime;
private DateTimeOffset runtimeCheckedAt;
internal SoftwareProbeService(AdapterExecutionService execution)
: this(execution.Probe)
{
}
internal SoftwareProbeService(Func<AdapterRuntimeStatus> probe)
{
this.probe = probe;
}
internal AdapterRuntimeStatus Detect()
{
lock (sync)
{
if (cachedRuntime is null
|| DateTimeOffset.UtcNow - runtimeCheckedAt > TimeSpan.FromSeconds(30))
{
cachedRuntime = probe();
runtimeCheckedAt = DateTimeOffset.UtcNow;
}
return cachedRuntime;
}
}
internal void Invalidate()
{
lock (sync)
{
cachedRuntime = null;
runtimeCheckedAt = default;
}
}
}

View File

@ -0,0 +1,25 @@
namespace Zcbot.WindowsNode;
internal enum SoftwarePathKind
{
None,
Executable,
Directory,
}
internal sealed record SoftwareDefinition(
string Id,
string DisplayName,
string RuntimeId,
SoftwarePathKind PathKind,
string? EnvironmentVariable,
string? RequiredRelativePath,
string RequirementsRelativePath);
internal sealed record SoftwareLocation(
bool Available,
string? Path,
string Source,
string Detail);
internal sealed record RuntimeInstallResult(string RuntimePath, string Detail);

View File

@ -3,6 +3,19 @@ using System.Reflection;
namespace Zcbot.WindowsNode;
internal interface IStartupRegistration
{
bool IsEnabled { get; }
void SetEnabled(bool enabled);
}
internal sealed class StartupRegistrationService : IStartupRegistration
{
public bool IsEnabled => StartupRegistration.IsEnabled;
public void SetEnabled(bool enabled) => StartupRegistration.SetEnabled(enabled);
}
internal static class StartupRegistration
{
private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";

View File

@ -0,0 +1,79 @@
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
namespace Zcbot.WindowsNode;
internal sealed class NodeHttpClient : IDisposable
{
private readonly HttpClient client;
internal NodeHttpClient(NodeConfig config, HttpMessageHandler? handler = null)
{
client = handler is null
? new HttpClient()
: new HttpClient(handler, disposeHandler: true);
client.BaseAddress = config.ServerUrl;
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", config.NodeToken);
client.DefaultRequestHeaders.Add("X-Node-Id", config.NodeId.ToString());
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.1.0";
client.DefaultRequestHeaders.UserAgent.ParseAdd($"zcbot-windows-node/{version}");
}
internal HttpRequestMessage CreateRequest(
HttpMethod method,
string path,
RecoverableJob? job = null,
HttpContent? content = null)
{
var request = new HttpRequestMessage(method, path) { Content = content };
if (job is not null)
{
request.Headers.Add("X-Lease-Id", job.LeaseId.ToString());
request.Headers.Add("X-Request-Digest", job.RequestDigest);
}
return request;
}
internal Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead,
CancellationToken cancellationToken = default) =>
client.SendAsync(request, completionOption, cancellationToken);
internal async Task<HttpResponseMessage> SendWithRetryAsync(
Func<HttpRequestMessage> requestFactory,
HttpCompletionOption completionOption,
CancellationToken cancellationToken)
{
for (var attempt = 0; ; attempt++)
{
using var request = requestFactory();
try
{
var response = await SendAsync(request, completionOption, cancellationToken);
var delay = TransferRetryPolicy.RetryDelay(
request.Method, attempt, response.StatusCode);
if (delay is null)
{
return response;
}
response.Dispose();
await Task.Delay(delay.Value, cancellationToken);
}
catch (HttpRequestException exception)
{
var delay = TransferRetryPolicy.RetryDelay(
request.Method, attempt, exception: exception);
if (delay is null)
{
throw;
}
await Task.Delay(delay.Value, cancellationToken);
}
}
}
public void Dispose() => client.Dispose();
}

View File

@ -0,0 +1,60 @@
using System.Net;
using System.Net.Http;
namespace Zcbot.WindowsNode;
internal enum TransferFailureKind
{
Authentication,
Transient,
Permanent,
Cancelled,
}
internal static class TransferRetryPolicy
{
private static readonly TimeSpan[] DownloadBackoff =
[
TimeSpan.FromMilliseconds(250),
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(3),
];
internal static TransferFailureKind Classify(HttpStatusCode statusCode)
{
if (statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
return TransferFailureKind.Authentication;
}
if (statusCode is HttpStatusCode.RequestTimeout
or HttpStatusCode.TooManyRequests
|| (int)statusCode >= 500)
{
return TransferFailureKind.Transient;
}
return TransferFailureKind.Permanent;
}
internal static TransferFailureKind Classify(Exception exception) => exception switch
{
OperationCanceledException => TransferFailureKind.Cancelled,
HttpRequestException or IOException => TransferFailureKind.Transient,
_ => TransferFailureKind.Permanent,
};
internal static TimeSpan? RetryDelay(
HttpMethod method,
int attempt,
HttpStatusCode? statusCode = null,
Exception? exception = null)
{
if (method != HttpMethod.Get || attempt >= DownloadBackoff.Length)
{
return null;
}
var kind = statusCode is HttpStatusCode status
? Classify(status)
: exception is not null ? Classify(exception) : TransferFailureKind.Permanent;
return kind == TransferFailureKind.Transient ? DownloadBackoff[attempt] : null;
}
}

View File

@ -1,290 +0,0 @@
namespace Zcbot.WindowsNode;
internal sealed class TrayApplicationContext : ApplicationContext
{
private readonly NodeConfigStore store;
private readonly ConfigurationForm form;
private readonly NotifyIcon tray;
private readonly ToolStripMenuItem statusItem;
private readonly ToolStripMenuItem exitItem;
private CancellationTokenSource? connectionStop;
private Task? connectionTask;
private NodeConnectionLoop? connectionLoop;
private NodeConfig? config;
private bool exiting;
private NodeStatus current = NodeStatus.Create(NodeState.NotRegistered, "尚未注册");
internal TrayApplicationContext(NodeConfigStore store)
{
this.store = store;
form = new ConfigurationForm();
// Create the hidden form handle before the connection loop starts. Fast local
// connections can otherwise report Connecting/Online before a handle exists,
// and PostStatus would discard both updates until the user reconnects manually.
_ = form.Handle;
form.RegisterRequested += RegisterAsync;
form.ReconnectRequested += RestartConnection;
form.ResetIdentityRequested += ResetIdentity;
form.DataRootMigrationRequested += MigrateDataRootAsync;
statusItem = new ToolStripMenuItem("尚未注册") { Enabled = false };
var menu = new ContextMenuStrip();
menu.Items.Add(statusItem);
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("打开配置", null, (_, _) => ShowConfiguration());
menu.Items.Add("立即重连", null, (_, _) => RestartConnection());
menu.Items.Add(new ToolStripSeparator());
exitItem = new ToolStripMenuItem("退出");
exitItem.Click += async (_, _) => await ExitNodeAsync();
menu.Items.Add(exitItem);
tray = new NotifyIcon
{
ContextMenuStrip = menu,
Icon = TrayIconFactory.Create(NodeState.NotRegistered),
Text = "zcbot Windows Node - 尚未注册",
Visible = true,
};
tray.DoubleClick += (_, _) => ShowConfiguration();
if (store.Exists)
{
try
{
config = store.Load();
UpdateStatus(NodeStatus.Create(NodeState.Connecting, "正在连接 zcbot…"));
StartConnection();
}
catch (NodeConfigurationException exception)
{
UpdateStatus(NodeStatus.Create(NodeState.AuthenticationRequired, exception.Message));
ShowConfiguration();
}
}
else
{
UpdateStatus(current);
ShowConfiguration();
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
connectionStop?.Cancel();
tray.Visible = false;
tray.Dispose();
form.Dispose();
connectionStop?.Dispose();
}
base.Dispose(disposing);
}
private async Task RegisterAsync(EnrollOptions options)
{
UpdateStatus(NodeStatus.Create(NodeState.Connecting, "正在注册节点…"));
try
{
await EnrollmentClient.EnrollAsync(options, store, CancellationToken.None);
config = store.Load();
UpdateStatus(NodeStatus.Create(NodeState.Connecting, "注册成功,正在连接…"));
StartConnection();
}
catch
{
UpdateStatus(NodeStatus.Create(NodeState.NotRegistered, "注册失败,请检查配置"));
throw;
}
}
private void StartConnection()
{
if (exiting || config is null || connectionTask is { IsCompleted: false })
{
return;
}
connectionStop?.Dispose();
connectionStop = new CancellationTokenSource();
connectionTask = RunConnectionAsync(config, connectionStop.Token);
}
private async Task RunConnectionAsync(NodeConfig nodeConfig, CancellationToken cancellationToken)
{
var loop = new NodeConnectionLoop(nodeConfig, status => PostStatus(status));
connectionLoop = loop;
try
{
await loop.RunAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
PostStatus(NodeStatus.Create(NodeState.Stopped, "连接已停止"));
}
catch (NodeConfigurationException exception)
{
PostStatus(NodeStatus.Create(NodeState.AuthenticationRequired, exception.Message));
}
catch (Exception exception)
{
PostStatus(NodeStatus.Create(NodeState.Offline, $"节点异常:{exception.Message}"));
}
finally
{
if (ReferenceEquals(connectionLoop, loop))
{
connectionLoop = null;
}
}
}
private void RestartConnection()
{
if (config is null)
{
ShowConfiguration();
return;
}
UpdateStatus(NodeStatus.Create(NodeState.Connecting, "等待本机任务收尾后重连…"));
connectionStop?.Cancel();
_ = RestartAfterStopAsync();
}
private void ResetIdentity()
{
connectionStop?.Cancel();
store.DeleteLocalIdentity();
config = null;
UpdateStatus(NodeStatus.Create(NodeState.NotRegistered, "本机身份已清除,请使用新注册码注册"));
}
private async Task RestartAfterStopAsync()
{
if (connectionTask is not null)
{
await connectionTask;
}
StartConnection();
}
private async Task<NodeDataMigrationResult> MigrateDataRootAsync(string targetRoot)
{
var sourceRoot = NodePaths.ForCurrentMachine().RootDirectory;
connectionStop?.Cancel();
try
{
if (connectionTask is not null)
{
await connectionTask;
}
var result = await NodeDataMigrator.MigrateAsync(
sourceRoot, targetRoot, CancellationToken.None);
NodeDataRootSettings.SaveUserRoot(result.TargetDirectory);
return result;
}
catch
{
StartConnection();
throw;
}
}
private void PostStatus(NodeStatus status)
{
if (form.IsHandleCreated)
{
form.BeginInvoke(() => UpdateStatus(status));
}
}
private void UpdateStatus(NodeStatus status)
{
current = status;
form.ApplyStatus(status, config);
statusItem.Text = CompactMenuText(status.Message, 24);
tray.Text = Limit($"zcbot Windows Node - {status.Message}", 63);
var oldIcon = tray.Icon;
tray.Icon = TrayIconFactory.Create(status.State);
oldIcon?.Dispose();
}
private void ShowConfiguration()
{
form.ApplyStatus(current, config);
form.Show();
form.WindowState = FormWindowState.Normal;
form.Activate();
}
private async Task ExitNodeAsync()
{
if (exiting)
{
return;
}
var activeJobs = new JobInboxStore(NodePaths.ForCurrentMachine().JobsDirectory)
.ReadJobSnapshots()
.Count(item => item.IsActive);
var cancelJobs = false;
if (activeJobs > 0)
{
var choice = MessageBox.Show(
$"当前有 {activeJobs} 个本机任务尚未结束。\n\n"
+ "选择“是”:停止接收新任务,等待现有任务完成后退出。\n"
+ "选择“否”:取消现有任务并退出。\n"
+ "选择“取消”:返回,不退出。",
"退出 zcbot Windows Node",
MessageBoxButtons.YesNoCancel,
MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button1);
if (choice == DialogResult.Cancel)
{
return;
}
cancelJobs = choice == DialogResult.No;
if (cancelJobs && MessageBox.Show(
"取消任务会终止正在运行的专业软件进程,未完成结果不会上传。确认取消任务并退出?",
"确认取消任务",
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button2) != DialogResult.Yes)
{
return;
}
}
exiting = true;
exitItem.Enabled = false;
if (tray.ContextMenuStrip is not null)
{
tray.ContextMenuStrip.Enabled = false;
}
form.Enabled = false;
UpdateStatus(NodeStatus.Create(
NodeState.Stopped,
cancelJobs
? "正在取消本机任务并退出…"
: activeJobs > 0 ? "等待本机任务完成后退出…" : "正在退出…"));
if (cancelJobs)
{
connectionLoop?.CancelActiveJobsForExit();
}
connectionStop?.Cancel();
if (connectionTask is not null)
{
await connectionTask;
}
tray.Visible = false;
ExitThread();
}
private static string Limit(string value, int length) =>
value.Length <= length ? value : value[..length];
private static string CompactMenuText(string value, int length)
{
var singleLine = value.Replace('\r', ' ').Replace('\n', ' ').Trim();
return singleLine.Length <= length
? singleLine
: singleLine[..(length - 1)] + "…";
}
}

View File

@ -18,12 +18,19 @@ internal static class TrayIconFactory
using var graphics = Graphics.FromImage(bitmap);
graphics.SmoothingMode = SmoothingMode.AntiAlias;
graphics.Clear(Color.Transparent);
using var background = new SolidBrush(Color.FromArgb(30, 41, 59));
using var background = new SolidBrush(Color.FromArgb(14, 40, 36));
using var badge = new SolidBrush(color);
using var textBrush = new SolidBrush(Color.White);
graphics.FillEllipse(background, 1, 1, 30, 30);
using var font = new Font("Segoe UI", 13, FontStyle.Bold, GraphicsUnit.Pixel);
graphics.DrawString("Z", font, textBrush, new PointF(8, 7));
using var logoBrush = new SolidBrush(Color.FromArgb(153, 246, 228));
using var logoShape = CreateRoundedRectangle(new RectangleF(1, 1, 30, 30), 7);
graphics.FillPath(background, logoShape);
graphics.FillPolygon(
logoBrush,
new PointF[]
{
new(8.5f, 8), new(23.5f, 8), new(23.5f, 11.5f),
new(14.5f, 21), new(23.5f, 21), new(23.5f, 24),
new(8.5f, 24), new(8.5f, 20.5f), new(17.5f, 11), new(8.5f, 11),
});
graphics.FillEllipse(badge, 20, 20, 11, 11);
using var border = new Pen(Color.White, 2);
graphics.DrawEllipse(border, 20, 20, 11, 11);
@ -40,6 +47,18 @@ internal static class TrayIconFactory
}
}
private static GraphicsPath CreateRoundedRectangle(RectangleF bounds, float radius)
{
var diameter = radius * 2;
var path = new GraphicsPath();
path.AddArc(bounds.Left, bounds.Top, diameter, diameter, 180, 90);
path.AddArc(bounds.Right - diameter, bounds.Top, diameter, diameter, 270, 90);
path.AddArc(bounds.Right - diameter, bounds.Bottom - diameter, diameter, diameter, 0, 90);
path.AddArc(bounds.Left, bounds.Bottom - diameter, diameter, diameter, 90, 90);
path.CloseFigure();
return path;
}
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyIcon(nint handle);

View File

@ -3,6 +3,7 @@
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<UseWPF>true</UseWPF>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
@ -10,8 +11,10 @@
<AssemblyName>Zcbot.WindowsNode</AssemblyName>
<RootNamespace>Zcbot.WindowsNode</RootNamespace>
<Version>0.2.0</Version>
<ApplicationIcon>Assets\zcbot.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<Resource Include="Assets\zcbot.ico" />
<PackageReference Include="JsonSchema.Net" Version="9.4.0" />
</ItemGroup>
<ItemGroup>

View File

@ -33,4 +33,4 @@
},
"net10.0-windows7.0/win-x64": {}
}
}
}

View File

@ -0,0 +1,116 @@
param(
[string]$OutputPath = (Join-Path $PSScriptRoot "..\Zcbot.WindowsNode\Assets\zcbot.ico")
)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Drawing
function New-RoundedRectanglePath {
param(
[System.Drawing.RectangleF]$Bounds,
[float]$Radius
)
$diameter = $Radius * 2
$path = [System.Drawing.Drawing2D.GraphicsPath]::new()
$path.AddArc($Bounds.Left, $Bounds.Top, $diameter, $diameter, 180, 90)
$path.AddArc($Bounds.Right - $diameter, $Bounds.Top, $diameter, $diameter, 270, 90)
$path.AddArc($Bounds.Right - $diameter, $Bounds.Bottom - $diameter, $diameter, $diameter, 0, 90)
$path.AddArc($Bounds.Left, $Bounds.Bottom - $diameter, $diameter, $diameter, 90, 90)
$path.CloseFigure()
return $path
}
function New-IconPngBytes {
param([int]$Size)
$bitmap = [System.Drawing.Bitmap]::new(
$Size,
$Size,
[System.Drawing.Imaging.PixelFormat]::Format32bppArgb
)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
$graphics.Clear([System.Drawing.Color]::Transparent)
$margin = $Size * 0.035
$radius = $Size * 0.22
$bounds = [System.Drawing.RectangleF]::new(
$margin,
$margin,
$Size - 2 * $margin,
$Size - 2 * $margin
)
$shape = New-RoundedRectanglePath -Bounds $bounds -Radius $radius
$background = [System.Drawing.SolidBrush]::new(
[System.Drawing.Color]::FromArgb(255, 14, 40, 36)
)
$logo = [System.Drawing.SolidBrush]::new(
[System.Drawing.Color]::FromArgb(255, 153, 246, 228)
)
$graphics.FillPath($background, $shape)
$points = [System.Drawing.PointF[]]@(
[System.Drawing.PointF]::new($Size * 0.265, $Size * 0.245),
[System.Drawing.PointF]::new($Size * 0.735, $Size * 0.245),
[System.Drawing.PointF]::new($Size * 0.735, $Size * 0.36),
[System.Drawing.PointF]::new($Size * 0.455, $Size * 0.655),
[System.Drawing.PointF]::new($Size * 0.735, $Size * 0.655),
[System.Drawing.PointF]::new($Size * 0.735, $Size * 0.755),
[System.Drawing.PointF]::new($Size * 0.265, $Size * 0.755),
[System.Drawing.PointF]::new($Size * 0.265, $Size * 0.645),
[System.Drawing.PointF]::new($Size * 0.545, $Size * 0.35),
[System.Drawing.PointF]::new($Size * 0.265, $Size * 0.35)
)
$graphics.FillPolygon($logo, $points)
$stream = [System.IO.MemoryStream]::new()
$bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
$bytes = $stream.ToArray()
$stream.Dispose()
$logo.Dispose()
$background.Dispose()
$shape.Dispose()
$graphics.Dispose()
$bitmap.Dispose()
return ,$bytes
}
$sizes = @(16, 24, 32, 48, 64, 128, 256)
$images = @($sizes | ForEach-Object { New-IconPngBytes -Size $_ })
$outputDirectory = Split-Path -Parent $OutputPath
[System.IO.Directory]::CreateDirectory($outputDirectory) | Out-Null
$stream = [System.IO.MemoryStream]::new()
$writer = [System.IO.BinaryWriter]::new($stream)
$writer.Write([uint16]0)
$writer.Write([uint16]1)
$writer.Write([uint16]$images.Count)
$offset = 6 + 16 * $images.Count
for ($index = 0; $index -lt $images.Count; $index++) {
$size = $sizes[$index]
$image = $images[$index]
$writer.Write([byte]$(if ($size -eq 256) { 0 } else { $size }))
$writer.Write([byte]$(if ($size -eq 256) { 0 } else { $size }))
$writer.Write([byte]0)
$writer.Write([byte]0)
$writer.Write([uint16]1)
$writer.Write([uint16]32)
$writer.Write([uint32]$image.Length)
$writer.Write([uint32]$offset)
$offset += $image.Length
}
foreach ($image in $images) {
$writer.Write([byte[]]$image)
}
$writer.Flush()
[System.IO.File]::WriteAllBytes($OutputPath, $stream.ToArray())
$writer.Dispose()
$stream.Dispose()
Write-Output "[OK] Generated $OutputPath"