feat(windows-node): complete WPF host migration
This commit is contained in:
parent
0d5cbb8407
commit
38deea7d1d
|
|
@ -10,6 +10,8 @@
|
|||
|
||||
- DeepSeek Flash 的思考过程恢复实时显示,不再等到正文开始后才集中出现。
|
||||
|
||||
- Windows 专业软件节点启用完整的新管理界面,节点状态、首次注册、软件环境、本机任务、ANSYS 验收和数据目录迁移均可直接管理;应用图标、导航、状态提示和窄窗口布局也更清晰,关闭窗口后仍会继续在托盘运行。
|
||||
|
||||
- 国内模型成本统计跟随最新公开价格更新,DeepSeek 会按实际峰谷时段和缓存命中量计算,历史调价后的记录也可审计矫正。
|
||||
|
||||
- GLM-5.3 Flash 替换旧版 GLM 并向默认档位开放;上传图片会直接交给主模型理解,长工具任务可延续既有分析状态。
|
||||
|
|
|
|||
|
|
@ -478,6 +478,8 @@ Core 在 Job offer 中附带由 capability 合同生成的 `request_summary`,
|
|||
|
||||
扩展现有 capability 的 feature 时修改共享契约、对应 Worker 和测试,不修改 Core 调度、Job 生命周期或 Node EXE;增加新专业软件时新增契约与一个 adapter 目录。Cloud 会在下一次契约查询时获得新校验和工具 schema;Node 在启动时从本地目录获得能力、版本和入口,并在首次连接及每次心跳中上报全部已发现 adapter。服务端用本次上报中自己具有共享契约的 capability 原子替换节点能力列表,未知能力不进入调度;管理端只读展示该结果,不保存人工能力选择。现有节点因此不需要清身份或重新注册,升级 Node 后重启一次即可在下一次心跳同步。当前选择明确的运维边界:zcbot 契约可热更新,不要求重启;本机 adapter 更新时先退出托盘 Node,整体替换 adapter 目录后重新启动,不重装、不替换 Node EXE,也不建设自动更新平台。当前 Node 仍按整机单执行槽保守串行,未来只有真实并行软件需求出现时,才把 slot 账本升级为 per-capability 租约,而不改变 Job 协议。
|
||||
|
||||
桌面宿主采用 WPF `Application` 持有唯一 GUI 消息循环,WPF 主窗口只通过 ViewModel/Command 消费应用服务与控制器状态;WinForms 被限制在 `NotifyIcon`、文件/目录选择、确认框和剪贴板互操作,不再存在第二套配置窗口或 Host 生命周期。托盘宿主负责把后台状态经 Dispatcher 投递到 WPF、控制窗口显隐,并复用应用控制器完成注册、重连、清身份、数据目录迁移与安全退出。CLI 参数在 WPF 初始化前分流,因此 `run --headless` 不创建窗口或托盘。节点概览、首次注册、登录自启动、专业软件/runtime、本机任务、ANSYS 固定验收和数据目录迁移均已进入 WPF;`SoftwareManagementService`、`JobMonitorService`、`AnsysAcceptanceService`、`DataRootManagementService` 与 `DiagnosticService` 分别封装固定业务边界,ViewModel 不解析 Job 落盘格式、不访问注册表、不创建 Worker,也不直接持有应用控制器。
|
||||
|
||||
Node 通过 `Authorization: Bearer` 与 `X-Node-Id` 建立 `/v1/software-nodes/connect` WebSocket。进程内 Connection Manager 保证同一节点单活,新连接关闭旧连接;`hello`/`heartbeat` 更新版本、容量、软件健康与最后在线时间。管理员禁用节点时先持久化禁用态,再关闭现有连接;断线收尾不得覆盖禁用态。当前单活只覆盖单 Web 进程,生产启用多实例前必须增加 Redis/PG fencing 或将 Node API 固定路由到单一控制面实例。
|
||||
|
||||
第二阶段已增加 `software_jobs`(专业软件任务)账本与 `origin.plot@v2` 的 offer/accept 骨架。用户只能在本人 task 下以幂等键提交固定 schema;云端规范化请求并记录 SHA-256,按当前进程真实在线、能力匹配、健康且有空闲 slot 的 Node 创建短期 offer。Node 再次校验 schema、图形类型和输出格式,使用 write-through、flush 与原子 rename 先落本机任务目录,再回 `job_accept`;重复 job 只有 digest 一致才接受。过期或发送失败的 offer 回到队列,lease、Node 和 digest 不匹配的响应被拒绝。Node 接收后云端进入 `dispatched` 而非 `running`,并将 slot 降为 0;只有固定 Worker 真正启动后才进入软件无关的 `software_running`,具体软件和操作由 capability/request 表达。
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-08-30(国内模型版本化价格、DeepSeek 峰谷计费与历史重算;未发版)
|
||||
最后更新:2026-08-31(Windows Node Host/WPF 重构阶段 0–7;未发版)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -20,6 +20,10 @@
|
|||
---
|
||||
## 已完成关键能力
|
||||
|
||||
- **08-31 / Unreleased / Windows Node 完整 WPF 管理界面**:本机任务、ANSYS 固定验收、诊断复制和数据目录迁移全部进入 WPF;任务页每秒刷新本地持久化记录并保持选择,验收支持进度、停止、通过报告校验与机器级执行门,数据迁移继续经过活动任务检查、停连、复制、SHA-256 校验、失败回滚和重启。新增 `JobMonitorService`、`AnsysAcceptanceService`、`DataRootManagementService` 与 `DiagnosticService`,删除经典 `ConfigurationForm` 和旧 `TrayApplicationContext`,WinForms 只保留托盘及受控系统对话框互操作;117 项 .NET 行为测试、28 项 Windows Node 源码专项、格式与 diff 检查通过,未启动专业软件、连接生产服务或读写真实节点数据。
|
||||
|
||||
- **08-31 / Unreleased / Windows Node Host/WPF 重构阶段 0–6 第二切片**:WPF 在节点概览、首次注册和运行设置基础上新增专业软件页,Origin、ANSYS、Blender 卡片通过 `SoftwareManagementService` 统一获取位置、runtime 和 probe 快照,可选择或恢复自动检测位置、安装/更新独立 Python 3.12 环境并取消安装。选择对话框仍限制在 Tray WinForms 互操作层,ViewModel 不访问注册表或启动进程;全机执行门会禁用安装,`ManagedRuntimeInstaller` 入口继续二次拒绝活动任务并保持临时验证、原子替换和失败 rollback。同步完成 WPF 视觉打磨:主题 token、圆角控件模板、导航选中态、状态色带/徽标、软件可用性标识、忙碌进度和窄窗口操作换行统一落地。ANSYS 验收、本机任务和数据目录迁移仍从经典配置进入。73 项 .NET 行为测试、191 项 Origin/ANSYS/Blender/合同/Node/UI 专项、格式与 diff 检查、Release 发布包及隔离 GUI/headless 烟测通过,未安装 runtime、启动专业软件、连接生产服务或读写真实节点数据。
|
||||
|
||||
- **08-30 / Unreleased / 国内模型版本化计费与历史矫正工具**:新增本地价格目录解析,直连 DeepSeek、豆包、智谱不再被 LiteLLM 滞后价目覆盖;DeepSeek 按 2026-08-16 官方新价及工作日 UTC 峰谷窗口计费,豆包使用人民币固定价,GLM 绑定国内 BigModel 标准按量价。主对话、Prompt 润色、自动标题、上下文折叠和知识库摘要统一提取缓存 usage 并保存价格 revision/tier/币种/汇率/分项快照;新增默认 dry-run、显式确认 apply/rollback 的历史重算脚本,可针对调价后 DeepSeek 事件审计并矫正成本。无 schema/migration,未连接或写入生产数据库。
|
||||
|
||||
- **08-27 / Unreleased / GLM-5.3 Flash + 通用 reasoning/多模态能力**:下架 GLM-5.1/5.2 的可选入口并以隐藏别名将存量 `glm.pro/pro52` 统一解析到 `glm.flash53`,Flash53 加入默认与专业档位;模型能力新增原生输入模态和 `none/tool_turn/conversation/provider_managed` reasoning 生命周期,历史状态仅向同一生产模型回放,DeepSeek 限当前工具轮、GLM 保留同模型会话并发送 `clear_thinking=false`。结构化图片附件仍以文件引用为事实源,只在 provider 请求边界安全物化为 Base64 `image_url`,GLM 不再注册 `look_at_image`,文档读取与生图/视频工具保持独立;无 schema/migration/依赖变化,未调用真实模型或连接生产数据库。
|
||||
|
|
|
|||
2
RUN.md
2
RUN.md
|
|
@ -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% 重传;这些本地结果不会反向覆盖云端终态。
|
||||
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
内网 MVP 的 Windows 执行节点,目标运行环境为 Windows 11 Enterprise + .NET 10 SDK 10.0.303。仓库根目录 `global.json` 固定 SDK patch;客户端只使用 .NET Windows Desktop Framework,不依赖第三方 NuGet 包。
|
||||
|
||||
当前实现托盘状态角标、配置与本机任务窗口、注册、DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连。Node Host 以语言无关的 job 目录协议负责持久化、下载、恢复、取消和上传,并从 EXE 同级 `adapters/*/adapter.json` 发现能力;每个 adapter 目录必须自带 manifest 指向的合同,Node 不读取共享合同目录。Host 只按该合同的 JSON Schema 通用校验请求,软件探测、语义校验和执行都由 Worker 完成。Worker 可为受管 Python 脚本或独立 EXE,manifest 独立声明每种软件的任务超时及可选 Workspace 状态。当前安装包带 Origin、ANSYS 和 `blender.scene.author@v3` Worker,但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录;Origin OPJU 与 Blender `.blend` 工程保存在各自的 `workspaces/<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 脚本或独立 EXE,manifest 独立声明每种软件的任务超时及可选 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 ID;ANSYS 验收位于“专业软件”页,数据目录打开与迁移位于“运行设置”页。每个任务的输出上传诊断日志保存在 `<data_root>\jobs\<job-id>\logs\node-output-upload.log`,记录阶段、产物文件名、重试次数和 Windows `HRESULT`,不记录 Node Token 或认证请求头;单文件达到 1 MiB 后轮转一份 `.1`。无界面运行使用:
|
||||
|
||||
```powershell
|
||||
Zcbot.WindowsNode.exe run --headless
|
||||
|
|
|
|||
|
|
@ -0,0 +1,774 @@
|
|||
# Windows Node Host 与 WPF 重构方案
|
||||
|
||||
> 状态:阶段 0–7 已落地,进入阶段 8 发布准备
|
||||
> 日期:2026-08-31
|
||||
> 适用范围:`windows-node/Zcbot.WindowsNode` 及其测试、打包与运行文档
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 Windows Node 已完成托盘常驻、节点注册、WebSocket 连接、任务持久化与恢复、Adapter 发现与执行、Workspace 管理、专业软件配置、受管 runtime 安装、本机任务查看、数据目录迁移和 ANSYS 真机验收。它已经从一个简单的内网托盘 MVP 演进为专业软件任务的稳定宿主。
|
||||
|
||||
当前实现仍是单个 .NET 10 WinForms `WinExe`,后台协议与执行能力总体边界正确,但 UI 和 Host 内部职责开始集中:
|
||||
|
||||
- `ConfigurationForm.cs` 约 1300 行,布局、状态、软件管理、任务展示、数据迁移和 ANSYS 验收混在同一窗口类中。
|
||||
- `TrayApplicationContext.cs` 同时管理托盘、窗口、注册、连接、重连、迁移和安全退出。
|
||||
- `NodeConnectionLoop.cs` 同时承担 WebSocket 会话、心跳、消息解析、任务编排、恢复、取消与终态回报。
|
||||
- `JobInboxStore.cs` 同时承担协议接单、磁盘持久化、状态流转、恢复扫描和 UI 展示读取。
|
||||
- `SoftwareRuntimeManager.cs` 以大体量静态类同时承担软件定义、位置存储、探测、环境注入和 runtime 安装。
|
||||
- 注册、下载和上传分别管理 HTTP 客户端,网络错误分类、重试和认证注入缺少统一边界。
|
||||
- 现有 Windows Node 测试中有较多源码字符串断言,能够保护若干安全约束,但难以验证真实状态转换和异步生命周期。
|
||||
|
||||
本次重构同时处理两个问题:
|
||||
|
||||
1. 将 WinForms 主窗口迁移为 WPF,使用 XAML、数据绑定和 MVVM 组织界面。
|
||||
2. 将 Node Host 重构为可测试、可组合、与 UI 无关的长期宿主架构。
|
||||
|
||||
本次不是服务端协议重写,也不是 Adapter 架构替换。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
### 2.1 用户与运维目标
|
||||
|
||||
- 保持现有节点身份、数据目录、任务、Workspace 和 runtime 原位兼容。
|
||||
- 保持解压发布包后直接运行的部署方式。
|
||||
- 保持同一 EXE 的托盘、`enroll` 和 `run --headless` 三种入口。
|
||||
- 提升 125%、150% 和 200% DPI 下的布局稳定性。
|
||||
- 让节点状态、软件状态、任务状态和长时间操作在界面中保持一致。
|
||||
- 让新增专业软件主要落在 Adapter 和软件定义,不再扩张单个巨型窗口。
|
||||
- 在出现连接、传输、Worker 或恢复故障时提供可定位但不泄露 Token 的本机诊断。
|
||||
|
||||
### 2.2 工程目标
|
||||
|
||||
- 建立唯一 Composition Root,GUI 与 headless 共用同一套 Host 组装。
|
||||
- 将 UI、应用编排、领域状态、协议传输和基础设施分层。
|
||||
- 以明确的 Host 生命周期替代托盘类直接管理连接任务。
|
||||
- 以明确的 Job 状态机约束接单、下载、执行、上传、取消和恢复。
|
||||
- 统一 HTTP、WebSocket、认证、错误分类和传输重试边界。
|
||||
- 统一 Adapter 目录发现、合同加载、软件 probe 和进程监督边界。
|
||||
- 对纯逻辑和关键异步流程增加 .NET 行为测试,减少脆弱的源码字符串测试。
|
||||
- 每个实施阶段都可构建、可打包、可验收和可回退。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
以下事项不随本轮重构进入实现:
|
||||
|
||||
- 不改服务端 HTTP/WebSocket URL、消息类型、字段语义或认证方式。
|
||||
- 不改 `node.json`、`jobs/`、`workspaces/`、`runtimes/` 的对外文件布局。
|
||||
- 不清理、重置或隐式迁移已有节点数据。
|
||||
- 不改 Adapter 的 `adapter.json + contract + worker + job directory` 协议。
|
||||
- 不引入 Python GUI、Electron、WebView 或 WinUI 3。
|
||||
- 不安装 Windows Service,不拆 Service/DesktopRunner 双进程。
|
||||
- 不增加自动更新平台、插件市场或在线 Adapter 覆盖。
|
||||
- 不增加多 Job 并行或 per-capability slot;继续保持整机单执行槽。
|
||||
- 不把 Adapter probe、软件语义或具体软件分支移入通用 Host。
|
||||
- 不在框架迁移时同时进行大规模视觉重设计。
|
||||
|
||||
## 4. 必须保持的兼容契约
|
||||
|
||||
| 契约 | 重构后要求 |
|
||||
|---|---|
|
||||
| 可执行文件 | 仍为 `Zcbot.WindowsNode.exe` |
|
||||
| GUI 启动 | 无参数启动托盘;未注册时显示主窗口,已注册时后台连接 |
|
||||
| CLI | `enroll` 参数、输出标签和退出码不变 |
|
||||
| Headless | `run --headless` 不初始化 WPF、窗口或托盘 |
|
||||
| 单实例 | 继续使用 `Local\\Zcbot.WindowsNode` Mutex |
|
||||
| 节点身份 | 继续读取现有 DPAPI `LocalMachine` 加密 Token |
|
||||
| 注册表 | 数据根目录、软件位置和当前账号 Run 项键名不变 |
|
||||
| 数据目录 | 默认值、环境变量优先级和旧 `%ProgramData%` 兼容不变 |
|
||||
| Job | 已落盘任务可由新版继续恢复、取消、上传和回报终态 |
|
||||
| Workspace | `current`、`rollback` 和 head Job 语义不变 |
|
||||
| Adapter | 继续从 EXE 同级 `adapters/*/adapter.json` 发现 |
|
||||
| 发布 | 完整包和独立 Adapter 包的目录结构不变 |
|
||||
| 服务端 | 无 DB migration、无 API 变化、无部署联动要求 |
|
||||
|
||||
任何需要改变以上契约的实现发现都必须暂停,单独形成兼容设计,不得借重构直接修改。
|
||||
|
||||
## 5. 总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Entry["Program / Composition Root"]
|
||||
Tray["TrayHost"]
|
||||
Wpf["WPF Views"]
|
||||
VM["ViewModels"]
|
||||
App["Application Services"]
|
||||
Host["NodeHost"]
|
||||
Session["NodeSession"]
|
||||
Jobs["JobCoordinator"]
|
||||
Transfers["TransferClient"]
|
||||
Adapters["AdapterCatalog / AdapterExecutor"]
|
||||
Persistence["Config / Job / Workspace Repositories"]
|
||||
Runtime["SoftwareRuntimeService"]
|
||||
Server["zcbot HTTP / WebSocket"]
|
||||
Workers["Python or EXE Workers"]
|
||||
Disk["Existing data root"]
|
||||
|
||||
Entry --> Tray
|
||||
Entry --> Host
|
||||
Tray --> Wpf
|
||||
Wpf --> VM
|
||||
VM --> App
|
||||
Tray --> App
|
||||
App --> Host
|
||||
Host --> Session
|
||||
Host --> Jobs
|
||||
Jobs --> Transfers
|
||||
Jobs --> Adapters
|
||||
Jobs --> Persistence
|
||||
App --> Runtime
|
||||
Session --> Server
|
||||
Transfers --> Server
|
||||
Adapters --> Workers
|
||||
Persistence --> Disk
|
||||
Runtime --> Disk
|
||||
```
|
||||
|
||||
### 5.1 分层规则
|
||||
|
||||
#### Presentation
|
||||
|
||||
包含 WPF View、ViewModel、托盘和对话框适配器。
|
||||
|
||||
- 可以引用 Application。
|
||||
- 不直接引用 `ClientWebSocket`、`HttpClient`、Registry、文件系统或进程。
|
||||
- XAML code-behind 只处理纯 View 行为,例如 PasswordBox 取值、窗口拖动和焦点。
|
||||
- 不在 `MainWindow.xaml.cs` 中编排节点业务。
|
||||
|
||||
#### Application
|
||||
|
||||
包含用户操作和宿主生命周期的用例编排。
|
||||
|
||||
- 注册、重连、清除身份、迁移数据目录、安全退出。
|
||||
- 软件位置变更、runtime 安装和固定验收。
|
||||
- 组合领域结果为不可变 UI Snapshot。
|
||||
- 不依赖 WPF 或 WinForms 类型。
|
||||
|
||||
#### Host / Domain
|
||||
|
||||
包含节点会话、Job 状态机、单槽执行、恢复和 Adapter 抽象。
|
||||
|
||||
- 不感知托盘、窗口、按钮或对话框。
|
||||
- 不通过任意字符串隐式改变 Job 状态。
|
||||
- 关键状态转换经过统一校验和持久化。
|
||||
|
||||
#### Infrastructure
|
||||
|
||||
包含 HTTP、WebSocket、JSON、DPAPI、Registry、文件、进程和软件探测实现。
|
||||
|
||||
- 向上提供明确的小接口或服务,不把 Win32/IO 类型扩散到 ViewModel。
|
||||
- 原子写、路径约束、日志脱敏和进程树终止在此集中实现。
|
||||
|
||||
## 6. 目标项目结构
|
||||
|
||||
第一轮保持单一 `Zcbot.WindowsNode.csproj`,先形成目录和命名空间边界,避免同时承担程序集拆分、`internal` 可见性和发布路径变化。完成后再依据依赖图决定是否拆出测试友好的 Core 项目。
|
||||
|
||||
```text
|
||||
Zcbot.WindowsNode/
|
||||
├── Program.cs
|
||||
├── App.xaml
|
||||
├── App.xaml.cs
|
||||
│
|
||||
├── Bootstrap/
|
||||
│ ├── NodeCompositionRoot.cs
|
||||
│ ├── NodeCommandLine.cs
|
||||
│ └── SingleInstanceGuard.cs
|
||||
│
|
||||
├── Application/
|
||||
│ ├── NodeApplicationController.cs
|
||||
│ ├── NodeApplicationSnapshot.cs
|
||||
│ ├── NodeShutdownDecision.cs
|
||||
│ ├── DiagnosticService.cs
|
||||
│ ├── JobMonitorService.cs
|
||||
│ ├── SoftwareManagementService.cs
|
||||
│ └── AnsysAcceptanceService.cs
|
||||
│
|
||||
├── Host/
|
||||
│ ├── NodeHost.cs
|
||||
│ ├── NodeHostSnapshot.cs
|
||||
│ ├── NodeSession.cs
|
||||
│ ├── NodeSessionState.cs
|
||||
│ └── NodeEvent.cs
|
||||
│
|
||||
├── Protocol/
|
||||
│ ├── NodeProtocolClient.cs
|
||||
│ ├── NodeProtocolCodec.cs
|
||||
│ ├── ServerMessage.cs
|
||||
│ ├── NodeMessage.cs
|
||||
│ └── NodeProtocolException.cs
|
||||
│
|
||||
├── Jobs/
|
||||
│ ├── JobCoordinator.cs
|
||||
│ ├── JobExecutionGate.cs
|
||||
│ ├── JobStateMachine.cs
|
||||
│ ├── JobRepository.cs
|
||||
│ ├── JobRecoveryService.cs
|
||||
│ └── JobSnapshot.cs
|
||||
│
|
||||
├── Transfers/
|
||||
│ ├── NodeHttpClient.cs
|
||||
│ ├── JobInputTransfer.cs
|
||||
│ ├── JobOutputTransfer.cs
|
||||
│ └── TransferRetryPolicy.cs
|
||||
│
|
||||
├── Adapters/
|
||||
│ ├── AdapterCatalog.cs
|
||||
│ ├── AdapterDescriptor.cs
|
||||
│ ├── AdapterContractLoader.cs
|
||||
│ ├── AdapterExecutionService.cs
|
||||
│ └── ProcessSupervisor.cs
|
||||
│
|
||||
├── Workspaces/
|
||||
│ ├── WorkspaceRepository.cs
|
||||
│ └── WorkspacePromotionService.cs
|
||||
│
|
||||
├── Runtime/
|
||||
│ ├── SoftwareCatalog.cs
|
||||
│ ├── SoftwareLocationService.cs
|
||||
│ ├── SoftwareProbeService.cs
|
||||
│ └── ManagedRuntimeInstaller.cs
|
||||
│
|
||||
├── Persistence/
|
||||
│ ├── NodeConfigStore.cs
|
||||
│ ├── NodeDataRootService.cs
|
||||
│ ├── AtomicFile.cs
|
||||
│ ├── PathGuard.cs
|
||||
│ └── RotatingNodeLog.cs
|
||||
│
|
||||
├── Presentation/
|
||||
│ ├── Tray/
|
||||
│ ├── ViewModels/
|
||||
│ ├── Views/
|
||||
│ ├── Commands/
|
||||
│ ├── Converters/
|
||||
│ └── Themes/
|
||||
│
|
||||
└── Compatibility/
|
||||
└── persisted model readers retained only where required
|
||||
```
|
||||
|
||||
目录是目标职责图,不要求通过一次机械移动完成;重构期间优先保证逻辑边界,最后再统一物理目录。
|
||||
|
||||
## 7. Host 核心设计
|
||||
|
||||
### 7.1 Composition Root
|
||||
|
||||
`NodeCompositionRoot` 是唯一对象组装入口:
|
||||
|
||||
- 解析一次数据根目录并生成 `NodePaths`。
|
||||
- 创建唯一 `NodeConfigStore`、`JobRepository`、`WorkspaceRepository` 和 `AdapterCatalog`。
|
||||
- 按当前节点配置创建共享 `NodeHttpClient` 和 `NodeProtocolClient`。
|
||||
- 创建 `JobCoordinator`、`NodeSession` 和 `NodeHost`。
|
||||
- GUI 与 headless 只决定 Presentation 和生命周期宿主,不各自复制 Host 构造逻辑。
|
||||
|
||||
不引入通用依赖注入容器;当前规模使用显式构造函数组装更便于审计、打包和故障定位。
|
||||
|
||||
### 7.2 NodeHost 生命周期
|
||||
|
||||
`NodeHost` 对外只暴露少量稳定操作:
|
||||
|
||||
```csharp
|
||||
Task RunAsync(CancellationToken cancellationToken);
|
||||
Task ReconnectAsync(CancellationToken cancellationToken);
|
||||
Task<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、专项测试、打包和启动烟测全部通过。
|
||||
|
||||
### 阶段 1:Composition Root 与应用控制器
|
||||
|
||||
- 引入唯一 `NodeCompositionRoot`。
|
||||
- 从托盘类提取注册、连接、重连、迁移和退出编排。
|
||||
- GUI 与 headless 共用 Host 构造路径。
|
||||
- WinForms 界面保持不变。
|
||||
|
||||
完成门槛:现有界面和 CLI 行为完全等价。
|
||||
|
||||
### 阶段 2:Job 持久化与状态机
|
||||
|
||||
- 提取 `JobRepository`、`JobStateMachine` 和 `JobRecoveryService`。
|
||||
- 集中原子文件和路径约束。
|
||||
- 保持现有落盘格式,验证新版能恢复旧 Job。
|
||||
- 增加状态机和恢复测试。
|
||||
|
||||
完成门槛:旧 Job 样本可恢复,终态与 Workspace 不回退。
|
||||
|
||||
### 阶段 3:会话、协议与传输
|
||||
|
||||
- 拆分 `NodeSession`、`NodeProtocolCodec` 和 `NodeProtocolClient`。
|
||||
- 引入共享 `NodeHttpClient`。
|
||||
- 统一错误分类、认证注入和安全重试。
|
||||
- 由 `JobCoordinator` 接管消息业务编排。
|
||||
|
||||
完成门槛:假服务端重连、offer、cancel、上传重试和身份拒绝测试通过。
|
||||
|
||||
### 阶段 4:Adapter、进程与 runtime
|
||||
|
||||
- 提取 `AdapterCatalog`、`AdapterExecutionService` 和 `ProcessSupervisor`。
|
||||
- 拆分软件位置、probe 和 runtime 安装。
|
||||
- 统一活动任务执行门和取消收尾。
|
||||
- 不改 Adapter 文件或合同。
|
||||
|
||||
完成门槛:Origin、ANSYS、Blender 现有 probe、执行、取消和打包测试通过。
|
||||
|
||||
### 阶段 5:WPF Shell 与托盘
|
||||
|
||||
- 启用 WPF,建立 App、MainWindow、资源字典和 ViewModel 基础设施。
|
||||
- 保留 WinForms NotifyIcon。
|
||||
- 完成无参数 GUI、CLI 和 headless 启动分流。
|
||||
- 先建立空页面与完整生命周期,不删除旧窗口。
|
||||
|
||||
完成门槛:单实例、后台启动、显示/隐藏和安全退出通过。
|
||||
|
||||
### 阶段 6:逐页迁移
|
||||
|
||||
按以下顺序迁移:
|
||||
|
||||
1. 节点概览与首次注册。
|
||||
2. 运行设置与数据目录迁移。
|
||||
3. 专业软件卡和 runtime 管理。
|
||||
4. ANSYS 固定验收。
|
||||
5. 本机任务列表和详情。
|
||||
|
||||
完成门槛:每一页迁移后均完成行为等价检查,不等待最后一次性验收。
|
||||
|
||||
### 阶段 7:移除 WinForms 主窗口与加固
|
||||
|
||||
- 删除 `ConfigurationForm` 和旧 `TrayApplicationContext`。
|
||||
- 保留 NotifyIcon 所需的最小 WinForms 依赖。
|
||||
- 清理重复状态、静态 Service Locator 和临时兼容代码。
|
||||
- 完成本机结构化日志与诊断摘要。
|
||||
- 完成全量专项测试和发布包真机烟测。
|
||||
|
||||
完成门槛:源码中没有第二套可达 UI 或重复 Host 生命周期。
|
||||
|
||||
### 阶段 8:文档与发布准备
|
||||
|
||||
- 架构落地后把稳定决策同步到根 `DESIGN.md`。
|
||||
- 如运行方式、诊断路径或部署步骤有变化,同步 `RUN.md` 和 `windows-node/README.md`。
|
||||
- 阶段性成果更新 `PROGRESS.md`。
|
||||
- WPF 稳定前不提升版本号,不创建已发布 Changelog 版本。
|
||||
- 准备上线时再通过独立 release commit 更新版本和用户版 Changelog。
|
||||
|
||||
## 12. 建议提交拆分
|
||||
|
||||
每个提交只包含一个可验证的逻辑变化:
|
||||
|
||||
1. `test(windows-node): characterize host lifecycle`
|
||||
2. `refactor(windows-node): add shared composition root`
|
||||
3. `refactor(windows-node): extract job state and persistence`
|
||||
4. `refactor(windows-node): separate session and protocol handling`
|
||||
5. `refactor(windows-node): unify job transfers`
|
||||
6. `refactor(windows-node): extract adapter process supervision`
|
||||
7. `refactor(windows-node): split software runtime services`
|
||||
8. `feat(windows-node): add wpf shell and tray host`
|
||||
9. `feat(windows-node): migrate overview and settings views`
|
||||
10. `feat(windows-node): migrate software and acceptance views`
|
||||
11. `feat(windows-node): migrate local job monitor`
|
||||
12. `refactor(windows-node): remove legacy winforms window`
|
||||
13. `test(windows-node): cover wpf and host integration`
|
||||
14. `docs(windows-node): document host and wpf architecture`
|
||||
|
||||
实施中可根据实际依赖合并相邻提交,但不得将 Host 重构、UI 视觉调整和发布版本提升混入同一提交。
|
||||
|
||||
## 13. 风险与控制
|
||||
|
||||
| 风险 | 控制措施 |
|
||||
|---|---|
|
||||
| 重连时重复启动任务 | Host 统一拥有会话代次和单槽执行门 |
|
||||
| 退出后残留 Worker | 所有进程归 `ProcessSupervisor`,停止时等待完整树退出 |
|
||||
| 上传重试导致重复执行 | 执行终态与传输状态分离,传输只重放幂等上传 |
|
||||
| 后台线程更新 WPF 集合 | Snapshot 在 Dispatcher 上映射到 ObservableCollection |
|
||||
| 新版无法恢复旧 Job | 不改落盘字段,使用历史样本做恢复测试 |
|
||||
| 数据迁移期间重新接单 | Controller 先停调度并等待安全屏障,再执行迁移 |
|
||||
| runtime 安装覆盖活动环境 | 本机执行门检查活动 Job,临时安装验证后原子替换 |
|
||||
| Token 泄露到日志或 UI | 集中认证客户端、脱敏器和诊断字段 allowlist |
|
||||
| WPF 与 WinForms 类型冲突 | WinForms 仅限 Tray 命名空间并使用完整类型名 |
|
||||
| 重构范围失控 | 非目标列表和阶段完成门槛作为 review gate |
|
||||
|
||||
## 14. 回退策略
|
||||
|
||||
- 整个改动在独立功能分支完成,WPF 稳定前不让线上用户接触。
|
||||
- 每个阶段保持可构建,Host 抽取阶段继续由旧 WinForms UI 驱动。
|
||||
- WPF 迁移期间旧窗口可作为开发对照,但最终合并前必须只有一套可达 UI。
|
||||
- 发布前保留上一版完整 Node 程序包;回退只替换程序目录,不修改 data root。
|
||||
- 因持久化格式保持兼容,回退旧 EXE 后仍能读取原有身份、Job 和 Workspace。
|
||||
- 若实施中确需新增持久化字段,必须做到旧版本忽略、新版本可缺省,且在本方案中补充独立兼容说明。
|
||||
|
||||
## 15. 完成定义
|
||||
|
||||
满足以下条件才视为重构完成:
|
||||
|
||||
- WinForms `ConfigurationForm` 已移除,主界面全部使用 WPF。
|
||||
- WinForms 仅用于 NotifyIcon 或已明确记录的必要互操作。
|
||||
- GUI 和 headless 从同一个 Composition Root 获得 Node Host。
|
||||
- `NodeConnectionLoop` 的会话、协议和 Job 编排职责已拆分。
|
||||
- `JobInboxStore` 的持久化、恢复、状态机和 UI 展示职责已拆分。
|
||||
- `SoftwareRuntimeManager` 的定义、位置、probe 和安装职责已拆分。
|
||||
- 关键 Host 生命周期和 Job 状态转换有真实 .NET 行为测试。
|
||||
- 旧节点身份、历史 Job 和 Workspace 在新版测试样本中可继续使用。
|
||||
- Origin、ANSYS、Blender Adapter 无需修改即可运行。
|
||||
- framework-dependent 与 self-contained 发布包均通过构建和启动烟测。
|
||||
- 真机托盘、DPI、runtime 安装、任务执行、取消、上传和安全退出验收通过。
|
||||
- `DESIGN.md`、`RUN.md`、`PROGRESS.md` 和 Windows Node README 已按实际落地范围同步。
|
||||
|
||||
## 16. 首个实施切片
|
||||
|
||||
建议第一个开发切片只做以下内容:
|
||||
|
||||
1. 增加 Host 生命周期 Characterization 测试。
|
||||
2. 引入 `NodeCompositionRoot`,消除 GUI 与 headless 的重复构造路径。
|
||||
3. 提取 `NodeApplicationController`,从托盘类移出注册、重连、迁移和退出编排。
|
||||
4. 让现有 WinForms UI 继续驱动新的 Controller。
|
||||
5. 完成 build、专项测试、打包和启动烟测。
|
||||
|
||||
该切片不启用 WPF、不移动 Job 格式、不改 Adapter,也不改变用户可见界面。它先建立后续 Host 重构和 WPF 迁移共同依赖的稳定边界,风险最低、验证价值最高。
|
||||
|
||||
### 16.1 实施记录(2026-08-31)
|
||||
|
||||
首个切片已完成:新增 `Zcbot.WindowsNode.Tests` 与 Host 生命周期行为测试;引入唯一 `NodeCompositionRoot` 和 UI 无关的 `NodeApplicationController`;GUI 与 `run --headless` 已共用组装入口;注册、重连、清除身份、迁移和安全退出编排已从托盘类移入 Controller。现有 WinForms 主窗口、协议、Job/Workspace 落盘格式、Adapter 和发布目录均保持不变。
|
||||
|
||||
验证已覆盖未注册启动、重连收尾屏障、幂等停止、取消退出、迁移失败重启和清身份收尾;.NET 测试、既有 Windows Node 源码专项测试、solution build、framework-dependent 打包及 GUI/headless 启动烟测通过。下一切片进入阶段 2,提取 Job 持久化、状态机与恢复决策。
|
||||
|
||||
### 16.2 阶段 2 实施记录(2026-08-31)
|
||||
|
||||
Job 持久化、状态和恢复边界已提取:`JobRepository` 直接负责既有 request/state/terminal/marker 目录的读取与原子写,`JobStateMachine` 约束正常单向转换并以显式 `Recovery` 模式兼容重启后的输入复验和历史成功任务续传,`JobRecoveryService` 将落盘事实分类为续执行、续上传、重放终态和无需动作。`AtomicFile` 与 `PathGuard` 集中保持同目录临时文件、flush 后替换和 Job 派生路径不越界;`JobInboxStore` 暂时保留 offer 校验、旧调用薄入口和 UI 展示读取,后续由协议与 `JobMonitorService` 切片继续收窄。
|
||||
|
||||
新增测试覆盖完整状态图、非法回退、终态不可重开、恢复例外、旧 request/state 样本无迁移读取、终态幂等、offer 先持久化及 digest/lease 重放、Workspace promotion/rollback、原子替换和路径穿越拒绝。40 项 .NET 行为测试、25 项既有源码专项、格式检查、Release 发布包及 headless 启动烟测通过;下一切片进入阶段 3,会话、协议与传输拆分。
|
||||
|
||||
### 16.3 阶段 3 实施记录(2026-08-31)
|
||||
|
||||
会话、协议和 Job 编排已从原连接循环分离:`NodeSession` 负责连接、hello、心跳、单接收循环与退避重连,`NodeProtocolCodec` 保持既有消息信封并允许未知消息按原策略忽略,`NodeProtocolClient` 集中处理文本分片、1 MiB 上限、身份关闭语义和发送锁;`JobCoordinator` 接管 offer、cancel、export、恢复回报及本地执行流水线,新的 `NodeConnectionLoop` 只组合并收尾两者。
|
||||
|
||||
输入下载和输出上传现共用每份已加载配置唯一的 `NodeHttpClient`,统一 BaseAddress、Bearer、Node ID 与 User-Agent;lease/digest 保持逐请求 Header,避免共享 Header 竞争。`TransferRetryPolicy` 将认证、瞬时、永久和取消错误分开,只有无请求体的 GET 下载执行有界自动重试,输出上传继续依赖既有服务端幂等确认与落盘恢复,不会因传输重试重新执行专业软件。新增假 WebSocket/HTTP handler 测试覆盖信封兼容、未知消息、分片接收、并发发送、4003 身份拒绝、断线重连、认证不重试和瞬时下载重试;57 项 .NET 行为测试、25 项源码专项、格式与 diff 检查、Release 发布包及隔离数据目录 headless 烟测通过。下一切片进入阶段 4,拆分 Adapter、进程监督与 runtime。
|
||||
|
||||
### 16.4 阶段 4 实施记录(2026-08-31)
|
||||
|
||||
Adapter 发现、合同、执行和进程边界已拆分:`AdapterCatalog` 以线程安全 Lazy 在启动期一次发现并缓存已安装描述符,`AdapterContractLoader` 负责 manifest 白名单、版本/runtime、相对路径边界、合同 capability、Workspace 输出、JSON Schema、入口扩展名和 SHA-256;`AdapterExecutionService` 保留既有 Job 目录协议、终态读取、Workspace promotion/restore 和错误码,所有 capability 共享 `JobExecutionGate`,不再只依赖云端 slot。`ProcessSupervisor` 统一固定进程启动、受限标准流采集、取消、完整进程树终止和 30 秒退出确认;对不存在 Job 的取消不再创建遗留 CTS。
|
||||
|
||||
软件与 runtime 已拆为 `SoftwareCatalog`、`SoftwareLocationService`、`SoftwareProbeService` 和 `ManagedRuntimeInstaller`。Origin/ANSYS/Blender 固定定义、兼容环境变量、注册表与标准目录检测顺序保持不变,probe 保持 30 秒缓存并可在 runtime 更新后显式失效;安装器仍只接受内置 requirements 和固定 Python 3.12 发现方式,在临时目录验证后原子替换并保留失败 rollback,同时在入口机械拒绝活动执行。新增行为测试覆盖已打包 Adapter 合同加载、跨 capability 单槽、等待取消、软件目录定义和 probe 缓存失效;62 项 .NET 行为测试、122 项 Origin/ANSYS/Blender/合同/Node 专项、格式与 diff 检查、Release 发布包及隔离数据目录 headless 烟测通过。下一切片进入阶段 5,建立 WPF Shell、ViewModel 基础设施及 NotifyIcon 互操作。
|
||||
|
||||
### 16.5 阶段 5 实施记录(2026-08-31)
|
||||
|
||||
GUI 入口已切换为 WPF `Application` 消息循环,新增 `MainWindow`、Light 资源字典、`ObservableObject`、同步/异步 Command 和 `MainWindowViewModel`。主窗口提供节点状态总览、隐藏到托盘、安全退出及经典配置入口;关闭窗口只隐藏,不终止后台节点。新的 `TrayHost` 通过显式 WinForms 类型别名管理 `NotifyIcon`,把 Controller 状态切回 WPF Dispatcher,延续托盘双击、重连、三选一活动任务退出和取消二次确认。尚未迁移的注册、专业软件、验收、本机任务和数据目录功能仍由按需创建的 `ConfigurationForm` 承担,阶段 8 完成前保持可达。
|
||||
|
||||
CLI 参数分支仍在创建任何 WPF 对象前执行,`run --headless`、单实例互斥体、Composition Root 和 Controller 生命周期保持原语义。新增 ViewModel 状态映射、命令转发、异步命令防重入与异常报告测试;66 项 .NET 行为测试、188 项 Origin/ANSYS/Blender/合同/Node 专项、格式与 diff 检查、framework-dependent Release 包、隔离 GUI 消息循环及 headless 返回码烟测通过。下一切片进入阶段 6,迁移节点概览、注册与运行设置页面。
|
||||
|
||||
### 16.6 阶段 6 第一切片实施记录(2026-08-31)
|
||||
|
||||
节点概览、首次注册和运行设置基础能力已迁入 WPF。`MainWindowViewModel` 现在持有页面导航、身份摘要、注册字段、操作反馈及 Command 可用状态,通过事件边界复用 Controller 的注册、重连和清身份流程;一次性注册码由 `PasswordBox` 同步到 ViewModel,注册成功或身份状态刷新后立即清空。清身份继续二次确认,注册继续使用 `EnrollOptions` 的 URL、名称和注册码校验。登录自启动经 `IStartupRegistration` 接口调用当前账号 `Run` 项,权限或 IO 失败会保留原开关状态并展示错误。运行设置展示实际 data root,迁移按钮暂时进入经典配置,以继续使用已有活动任务检查、固定磁盘校验、复制摘要和重启流程。
|
||||
|
||||
专业软件、runtime、ANSYS 验收、本机任务和数据目录迁移尚未迁入 WPF,因此本记录不把阶段 6 标为整体完成。新增测试覆盖 WPF 页面导航、身份映射、注册参数转发、自启动成功与失败回滚,源码契约确认 WPF 不直接依赖 Controller;69 项 .NET 行为测试、189 项 Origin/ANSYS/Blender/合同/Node 专项、格式与 diff 检查、framework-dependent Release 包及隔离 GUI/headless 烟测通过。下一切片迁移专业软件卡与 runtime 管理。
|
||||
|
||||
### 16.7 阶段 6 第二切片实施记录(2026-08-31)
|
||||
|
||||
专业软件卡和 runtime 管理已迁入 WPF。新的 `SoftwareManagementService` 组合 `SoftwareCatalog`、`SoftwareLocationService`、Adapter probe 和 `ManagedRuntimeInstaller`,向 Presentation 只暴露不可变快照与受控操作;`SoftwarePageViewModel` 在用户进入页面后异步刷新 Origin、ANSYS、Blender 卡片,支持选择严格匹配的 EXE/安装根目录、清除账号级配置后恢复自动检测、安装或更新隔离 runtime,以及显式取消安装。文件和目录对话框、安装确认仍封装在 Tray WinForms 互操作层,WPF ViewModel 不读取注册表、不创建进程,也不接触任意命令。
|
||||
|
||||
软件快照直接读取 `AdapterCatalog.HasActiveExecution`,全机任一 Job 执行时均禁用 runtime 安装;即使 UI 快照过期,安装器入口仍会二次拒绝活动执行。安装继续固定 Python 3.12、内置 requirements 和镜像配置,在临时目录验证后原子切换,取消时终止完整安装进程树,失败保持原 runtime。新增测试覆盖软件卡刷新、位置保存与清除、安装确认/结果、活动任务禁用和取消收尾;73 项 .NET 行为测试、190 项 Origin/ANSYS/Blender/合同/Node 专项、格式与 diff 检查、framework-dependent Release 包及隔离 GUI/headless 烟测通过,未执行真实安装或启动专业软件。下一切片迁移 ANSYS 固定验收。
|
||||
|
||||
### 16.8 WPF UI 打磨记录(2026-08-31)
|
||||
|
||||
在不改变页面命令和 Host 生命周期的前提下,Light 主题补齐集中颜色、表面、边框、状态和间距 token,并用 WPF ControlTemplate 统一按钮与侧栏导航的圆角、悬停、按下、禁用和键盘焦点状态。侧栏使用单选视觉的 ToggleButton 显示当前页,页头增加实时节点状态徽标,概览卡增加同源状态色带,软件卡增加位置可用性徽标和安装忙碌进度;操作区改用 WrapPanel,在 760 px 最小窗口下可自动换行。操作结果改为独立提示条,仅在有消息时占位,减少与底部常驻说明的竞争。
|
||||
|
||||
新增 UI 结构契约覆盖导航选中绑定、状态 tone、响应式操作区、忙碌反馈和主题资源,既有 ViewModel 测试补充状态色与提示可见性;73 项 .NET 行为测试、191 项 Origin/ANSYS/Blender/合同/Node/UI 专项、格式与 diff 检查、framework-dependent Release 包及隔离 GUI/headless 烟测通过。纯视觉改动未改变 CLI、托盘、注册、runtime 安装或持久化契约。
|
||||
|
||||
### 16.9 阶段 6 第三切片实施记录(2026-08-31)
|
||||
|
||||
按主导航可用性优先,将本机任务迁移提前到 ANSYS 固定验收之前。`JobMonitorService` 从 `JobInboxStore` 接管面向 UI 的只读快照生成,继续合并 request、state、terminal、cloud-terminal 和 upload-complete 落盘事实,不改变既有 Job 目录或恢复协议。新的 `JobPageViewModel` 负责状态中文映射、执行时长、摘要、选中详情与按 Job ID 保持选择;WPF 页面以只读表格展示最近 50 条记录,进入页面立即刷新,并在窗口可见且任务页激活时每秒刷新。侧栏“本机任务”不再打开经典配置。
|
||||
|
||||
新增测试覆盖快照刷新、状态格式、读取失败、刷新后选择保持,以及真实 WPF 资源加载后的四页互斥可见性和标题绑定;94 项 .NET 行为测试、28 项 Windows Node 源码专项、格式与 diff 检查通过。阶段 6 尚余 ANSYS 固定验收和数据目录完整迁移,完成后再删除经典任务页及其 WinForms 定时器,避免迁移期间影响现有回退入口。
|
||||
|
||||
### 16.10 阶段 6 完成记录(2026-08-31)
|
||||
|
||||
ANSYS 固定验收和数据目录完整迁移已进入 WPF。`AnsysAcceptanceService` 固定绑定已打包的 `ansys.mechanical.static_structural@v2` 验收入口,ViewModel 提供报告目录选择、长时进度、显式停止、通过报告校验和机器级执行门;执行门仍要求本次报告 `passed=true`、用户确认许可证已释放,并在权限不足时保持关闭。`DataRootPageViewModel` 通过 `DataRootManagementService` 检查环境变量托管与未完成任务,目标规范化和二次确认后只调用 Controller 的迁移编排,继续保持停连、复制、逐文件 SHA-256 校验、成功后保存新根目录与失败重连回滚。
|
||||
|
||||
### 16.11 阶段 7 完成记录(2026-08-31)
|
||||
|
||||
诊断摘要提取为 `DiagnosticService` 并由 WPF 概览页复制,内容继续排除 Node Token。删除 `ConfigurationForm` 和旧 `TrayApplicationContext`,托盘菜单不再暴露经典配置入口;WinForms 仅保留 `NotifyIcon`、固定文件/目录选择、确认框和剪贴板互操作。Presentation 不直接持有 Controller,不解析 Job JSON,不访问注册表,也不创建 Worker;117 项 .NET 行为测试、28 项 Windows Node 源码专项、格式与 diff 检查通过。下一步进入阶段 8 发布准备与目标机 DPI/GUI 烟测。
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
global using System.IO;
|
||||
global using System.Net.Http;
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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++;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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", "安装完成");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
<Solution>
|
||||
<Project Path="Zcbot.WindowsNode.Tests/Zcbot.WindowsNode.Tests.csproj" />
|
||||
<Project Path="Zcbot.WindowsNode/Zcbot.WindowsNode.csproj" />
|
||||
</Solution>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
global using System.IO;
|
||||
global using System.Net.Http;
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Microsoft.Win32;
|
||||
using System.IO;
|
||||
using System.Security;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)] + "…";
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Zcbot.WindowsNode.Tests")]
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)] + "…";
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -33,4 +33,4 @@
|
|||
},
|
||||
"net10.0-windows7.0/win-x64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue