feat(node): simplify console and protect active jobs

This commit is contained in:
caoqianming 2026-08-21 10:44:23 +08:00
parent bf6f8aa1f6
commit 1e7edcb8b7
6 changed files with 246 additions and 80 deletions

View File

@ -10,7 +10,9 @@
- 修复国际旗舰模型输出思考过程时偶尔无法及时停止的问题;即使模型暂时没有返回新的流式片段,点击“停止”也会迅速中断当前回答。
- Windows Node 主窗口重新整理为更宽、更清晰的桌面控制台节点概览、专业软件、本机任务和运行设置改用独立标签页节点状态、能力概览和维护操作分层展示专业软件使用独立配置行和通栏地址框Origin、ANSYS、Blender 均可手工选择位置,软件卡的操作按钮在高 DPI 下也会完整显示Origin 自动检测也会在 COM 路径无效时继续查找注册表和标准安装目录。
- Windows Node 主窗口改为简洁的单层自适应布局移除固定宽度与重复卡片边框窗口缩放时内容和页签会随可用宽度调整专业软件的状态与操作也会在窄窗口中自然换行能力概览按软件归并不再为同一软件或同名能力重复显示名称。Origin、ANSYS、Blender 均可手工选择位置Origin 自动检测也会在 COM 路径无效时继续查找注册表和标准安装目录。
- Windows Node 从托盘退出时会保护已有任务:可以停止接单并等待任务完成后退出,也可以经二次确认取消任务并终止对应的软件进程,不再直接关闭节点而留下状态不明的任务。
- Windows Node 现在可在“专业软件”区域自动检测或手工指定应用位置,并分别安装、更新 Origin、ANSYS、Blender 的独立运行环境;只装有部分专业软件的节点不再需要处理无关软件。

View File

@ -20,9 +20,11 @@
---
## 已完成关键能力
- **08-21 / Unreleased / Windows Node 任务安全退出**:托盘退出在存在活动任务时提供等待完成、取消任务或返回三种选择;默认等待路径先断开调度并复用连接循环既有的任务/上传收尾屏障,取消路径二次确认后同时取消输入下载与 adapter 进程树、持久化 `NODE_EXIT_CANCELLED` 终态,最终都等待连接任务结束后才关闭消息循环,等待期间禁用重复操作与重连,避免孤儿 Worker、并发恢复和重启误判。Windows Node、专业软件节点与合同共 68 项 unittest、.NET build 与 diff 检查通过,无 DB、schema、HTTP API、依赖或运行方式变化。
- **08-21 / Unreleased / Blender 多视图与剖切 v2**:以 `blender.scene.author@v2` 直接替换 v1目标节点为 Blender 5.2 LTS`rotary_kiln` 一次建模后可连续渲染等轴测、侧视、窑头、窑尾、纵向剖切和横截面,完整工程保持未剖切状态;固定等轴测预览外,其他选定 PNG 可动态自动回传GLB 排除仅供剖切渲染的辅助几何。专业软件提交工具改为区分缺失参数与合法空数组,允许 Blender 这类无输入、无额外导出请求的纯生成任务进入合同校验90 项 Blender/合同/Job/Node/预览专项 unittest、.NET build、独立 adapter 打包和 Blender 3.6.12 兼容烟测通过,全量 698 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项),目标节点 5.2 真机验收待部署后执行,未连接或迁移生产数据库。
- **08-21 / Unreleased / Windows Node 软件地址选择与 Origin 检测修正**:专业软件改为带边界的独立配置行和通栏只读地址框Origin、ANSYS、Blender 均可手选位置。Origin 自动检测由单一 COM 路径扩展为 COM、卸载注册表和标准目录回退手选的 `Origin*.exe` 同时用于 Worker 版本识别COM 执行健康仍由 probe 独立校验。默认窗口扩大为桌面控制台比例,节点状态改为独立状态徽标,能力概览只保留软件与 adapter 的可扫读摘要完整错误继续通过诊断复制提供。Windows Node 源码与 Origin Worker 专项 unittest、.NET build 与 diff 检查通过,无 DB、schema、HTTP API、依赖或运行方式变化。
- **08-21 / Unreleased / Windows Node 软件地址选择、Origin 检测与自适应界面**:专业软件保留一层带边界的独立配置行和通栏只读地址框Origin、ANSYS、Blender 均可手选位置。Origin 自动检测由单一 COM 路径扩展为 COM、卸载注册表和标准目录回退手选的 `Origin*.exe` 同时用于 Worker 版本识别COM 执行健康仍由 probe 独立校验。主窗口移除固定宽度居中容器和普通内容卡片边框,改为单层全宽页面;页签宽度跟随窗口调整,软件状态与操作分行并允许按钮自然换行,能力概览按软件与能力名称归并旧、新 adapter 的重复文案。Windows Node 22 项源码 unittest、.NET build 与 diff 检查通过,无 DB、schema、HTTP API、依赖或运行方式变化。
- **08-20 / Unreleased / Windows Node 专业软件独立配置与 runtime 安装**:主窗口新增 Origin、ANSYS Mechanical 2024 R2、Blender 软件卡,分别显示应用位置来源和受管 runtime 状态;三种软件均可由 UI 选择自定义位置清除后按各自的环境变量、COM、卸载注册表、PATH 或标准目录自动检测。每个软件可单独创建或更新隔离的 Python 3.12 runtime安装只使用内置 runtime 定义与固定 requirements拒绝活动任务期间替换并先在临时目录完成后再切换配置后的路径由 Host 作为固定子进程环境传给 Worker任务请求仍不能提供路径或命令。移除重复承担软件安装和开机启动的统一 BAT发布包直接运行 EXE当前账号的开机启动也完全由 UI 管理;全新节点默认使用可写的 `%LocalAppData%` 数据根目录,已有 `%ProgramData%` 节点原位兼容。

View File

@ -165,8 +165,8 @@ class WindowsNodeSourceTests(unittest.TestCase):
software_panel = form.split("private Control CreateSoftwarePanel", 1)[1].split(
"private void RefreshSoftwareCards", 1
)[0]
self.assertIn("actions.AutoSizeMode = AutoSizeMode.GrowAndShrink", software_panel)
self.assertIn("actions.WrapContents = false", software_panel)
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)
for runtime_id in ('"origin"', '"ansys"', '"blender"'):
@ -187,17 +187,19 @@ class WindowsNodeSourceTests(unittest.TestCase):
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(1320, 820)", form)
self.assertIn("MinimumSize = new Size(1040, 700)", form)
self.assertIn("ClientSize = new Size(1080, 720)", 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("CreateCard", form)
self.assertIn("CreateSection", form)
self.assertIn("注册并连接", form)
self.assertIn("ContentWidth = 1180", 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)
@ -205,15 +207,21 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn("SizeMode = TabSizeMode.Fixed", form)
self.assertIn("new NavigationTabControl", form)
self.assertIn("DrawMode = TabDrawMode.OwnerDrawFixed", form)
self.assertIn("Appearance = TabAppearance.FlatButtons", form)
self.assertIn("Math.Clamp(ClientSize.Width / TabCount, 96, 140)", form)
self.assertIn("tab.Controls.Add(content)", form)
self.assertIn("CreateSubsectionTitle(\"能力概览\")", 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
)
self.assertIn('$"{softwareName} {runtime.SoftwareVersion}"', form)
self.assertNotIn('$"Origin {runtime.SoftwareVersion}"', form)
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)
@ -228,6 +236,23 @@ class WindowsNodeSourceTests(unittest.TestCase):
tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8")
self.assertIn("form.ReconnectRequested += RestartConnection", 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")
self.assertIn("private async Task ExitNodeAsync()", tray)
self.assertIn("if (exiting || config is null", tray)
self.assertIn("ReadJobSnapshots()", tray)
self.assertIn("MessageBoxButtons.YesNoCancel", tray)
self.assertIn("connectionLoop?.CancelActiveJobsForExit()", tray)
self.assertIn("tray.ContextMenuStrip.Enabled = false", tray)
self.assertLess(tray.index("await connectionTask"), tray.index("ExitThread();"))
self.assertIn("internal void CancelActiveJobsForExit()", connection)
self.assertIn("adapters.Find(job.Capability)?.Cancel(job.JobId)", connection)
self.assertIn("inputDownloader.DownloadAsync(job, forcedStop.Token)", connection)
self.assertIn('"NODE_EXIT_CANCELLED"', connection)
self.assertIn("catch (OperationCanceledException) when (forcedStop.IsCancellationRequested)", connection)
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")
@ -344,8 +369,8 @@ class WindowsNodeSourceTests(unittest.TestCase):
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('Adapter {adapter.AdapterVersion}', form)
self.assertIn('$"{softwareName} 版本未知"', form)
self.assertIn('AppendLine($"Adapter: {adapter.AdapterVersion}")', form)
self.assertIn('$"{group.Key} · 版本未知"', form)
self.assertIn(
"&& !jobInbox.HasPendingJobs",
connection,
@ -398,7 +423,8 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn("ReportRecoverableJobsAsync", connection)
self.assertIn("ConcurrentDictionary<Guid, Task> jobPipelines", connection)
self.assertIn("StartJobPipeline(socket, acceptedJob)", connection)
self.assertIn("inputDownloader.DownloadAsync(job, CancellationToken.None)", 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)

View File

@ -8,8 +8,6 @@ namespace Zcbot.WindowsNode;
internal sealed class ConfigurationForm : Form
{
private const int ContentWidth = 1180;
private readonly TextBox server = CreateTextBox("http://127.0.0.1:8765");
private readonly TextBox nodeName = CreateTextBox(Environment.MachineName.ToLowerInvariant());
private readonly TextBox enrollmentCode = CreateTextBox(usePassword: true);
@ -76,25 +74,23 @@ internal sealed class ConfigurationForm : Form
adapters = NodeAdapterRegistry.CreateDefault(jobInbox);
Text = "zcbot Windows Node";
AutoScaleMode = AutoScaleMode.Dpi;
ClientSize = new Size(1320, 820);
MinimumSize = new Size(1040, 700);
ClientSize = new Size(1080, 720);
MinimumSize = new Size(760, 560);
StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Microsoft YaHei UI", 9);
FormBorderStyle = FormBorderStyle.Sizable;
BackColor = Color.FromArgb(246, 248, 252);
BackColor = Color.White;
var shell = new TableLayoutPanel
{
Dock = DockStyle.Fill,
AutoScroll = true,
ColumnCount = 3,
ColumnCount = 1,
RowCount = 1,
Padding = new Padding(0, 28, 0, 28),
Padding = new Padding(24, 20, 24, 20),
BackColor = BackColor,
};
shell.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
shell.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, ContentWidth));
shell.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
shell.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
shell.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
Controls.Add(shell);
var page = new TableLayoutPanel
@ -107,28 +103,20 @@ internal sealed class ConfigurationForm : Form
page.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
page.RowStyles.Add(new RowStyle(SizeType.AutoSize));
page.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
shell.Controls.Add(page, 1, 0);
shell.Controls.Add(page, 0, 0);
var heading = new TableLayoutPanel
{
AutoSize = true,
Dock = DockStyle.Top,
ColumnCount = 1,
Margin = new Padding(4, 0, 4, 16),
Margin = new Padding(2, 0, 2, 14),
};
heading.Controls.Add(new Label
{
Text = "LOCAL EXECUTION NODE",
AutoSize = true,
Font = new Font("Segoe UI", 8, FontStyle.Bold),
ForeColor = Color.FromArgb(37, 99, 235),
Margin = new Padding(1, 0, 0, 5),
});
heading.Controls.Add(new Label
{
Text = "zcbot Windows Node",
AutoSize = true,
Font = new Font("Microsoft YaHei UI", 23, FontStyle.Bold),
Font = new Font("Microsoft YaHei UI", 18, FontStyle.Bold),
ForeColor = Color.FromArgb(15, 23, 42),
});
var headingHint = CreateHint("连接本机科研软件与 zcbot安全执行受控任务");
@ -141,8 +129,8 @@ internal sealed class ConfigurationForm : Form
Dock = DockStyle.Fill,
Margin = new Padding(0),
Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold),
ItemSize = new Size(142, 38),
Padding = new Point(18, 6),
ItemSize = new Size(128, 38),
Padding = new Point(14, 6),
SizeMode = TabSizeMode.Fixed,
};
var overviewPage = AddTab(tabs, "节点概览");
@ -151,7 +139,7 @@ internal sealed class ConfigurationForm : Form
var settingsPage = AddTab(tabs, "运行设置");
page.Controls.Add(tabs, 0, 1);
var statusCard = CreateCard();
var statusCard = CreateSection();
var statusHeader = new TableLayoutPanel
{
AutoSize = true,
@ -176,7 +164,7 @@ internal sealed class ConfigurationForm : Form
statusCard.Controls.Add(resetActions);
overviewPage.Controls.Add(statusCard);
var softwareCard = CreateCard();
var softwareCard = CreateSection();
softwareCard.Controls.Add(CreateSectionTitle("专业软件"));
softwareCard.Controls.Add(CreateHint(
"只需配置这台电脑实际拥有的软件。应用位置和运行环境彼此独立,不会影响其他软件。"));
@ -186,7 +174,8 @@ internal sealed class ConfigurationForm : Form
}
softwarePage.Controls.Add(softwareCard);
ansysAcceptanceCard = CreateCard();
ansysAcceptanceCard = CreateSection();
ansysAcceptanceCard.Controls.Add(CreateDivider());
ansysAcceptanceCard.Controls.Add(CreateSectionTitle("ANSYS Mechanical 真机验收"));
ansysAcceptanceCard.Controls.Add(CreateHint(
"执行门关闭时也可本地验收。程序使用自带的标准试件,自动建立 fixed_face 和 load_face先验证完整进程树取消再连续求解 20 次并检查结果与进程释放。"));
@ -198,7 +187,7 @@ internal sealed class ConfigurationForm : Form
ansysAcceptanceCard.Controls.Add(acceptanceActions);
softwarePage.Controls.Add(ansysAcceptanceCard);
var jobsCard = CreateCard();
var jobsCard = CreateSection();
jobsCard.Controls.Add(CreateSectionTitle("本机任务"));
jobsCard.Controls.Add(CreateHint(
"仅显示已经派发到本机的任务;状态来自本地持久化记录,断线或重启后仍可查看。"));
@ -207,7 +196,8 @@ internal sealed class ConfigurationForm : Form
jobsCard.Controls.Add(jobDetail);
jobsPage.Controls.Add(jobsCard);
registrationCard = CreateCard();
registrationCard = CreateSection();
registrationCard.Controls.Add(CreateDivider());
registrationCard.Controls.Add(CreateSectionTitle("首次注册"));
registrationCard.Controls.Add(CreateHint(
"从 zcbot 管理后台生成注册码。注册码默认 10 分钟有效,成功注册一次后立即失效。"));
@ -219,7 +209,7 @@ internal sealed class ConfigurationForm : Form
registrationCard.Controls.Add(registerActions);
overviewPage.Controls.Add(registrationCard);
var runtimeCard = CreateCard();
var runtimeCard = CreateSection();
runtimeCard.Controls.Add(CreateSectionTitle("运行设置"));
runtimeCard.Controls.Add(startAtLogin);
AddField(
@ -313,7 +303,7 @@ internal sealed class ConfigurationForm : Form
: $"节点:{config.NodeName}\nNode ID{config.NodeId}\n服务{config.ServerUrl}";
capabilitySummary.Text = config is null
? "注册后启用"
: string.Join("\n", adapters.All.Select(FormatAdapterStatus));
: FormatCapabilitySummary(adapters.All);
copyDiagnostics.Enabled = config is not null;
UpdateAnsysAcceptanceStatus();
@ -330,18 +320,48 @@ internal sealed class ConfigurationForm : Form
}
}
private static string FormatAdapterStatus(INodeAdapter adapter)
private static string FormatCapabilitySummary(IEnumerable<INodeAdapter> values)
{
var entries = values.Select(adapter =>
{
var runtime = adapter.DetectRuntime();
var softwareName = runtime.Software == "OriginPro" ? "Origin" : runtime.Software;
var softwareVersion = string.IsNullOrWhiteSpace(runtime.SoftwareVersion)
? $"{softwareName} 版本未知"
: $"{softwareName} {runtime.SoftwareVersion}";
var state = runtime.Health == "ready" ? "可用" : "需配置";
return $"{adapter.DisplayName} {state}\n"
+ $"{softwareVersion} · Adapter {adapter.AdapterVersion}";
return new CapabilitySummaryEntry(
softwareName,
runtime.SoftwareVersion,
ShortCapabilityName(adapter.DisplayName, softwareName),
runtime.Health == "ready");
}).ToArray();
return string.Join("\n\n", entries
.GroupBy(entry => entry.SoftwareName, StringComparer.Ordinal)
.Select(group =>
{
var versions = group
.Select(entry => entry.SoftwareVersion)
.Where(version => !string.IsNullOrWhiteSpace(version))
.Distinct(StringComparer.Ordinal)
.ToArray();
var heading = versions.Length == 0
? $"{group.Key} · 版本未知"
: $"{group.Key} {string.Join(" / ", versions)}";
var capabilities = group
.GroupBy(entry => entry.CapabilityName, StringComparer.Ordinal)
.Select(capability =>
{
var state = capability.All(entry => entry.Ready)
? "可用"
: capability.Any(entry => entry.Ready) ? "部分可用" : "需配置";
return $" {capability.Key} {state}";
});
return string.Join("\n", capabilities.Prepend(heading));
}));
}
private static string ShortCapabilityName(string displayName, string softwareName) =>
displayName.StartsWith(softwareName + " ", StringComparison.OrdinalIgnoreCase)
? displayName[(softwareName.Length + 1)..]
: displayName;
private Control CreateSoftwarePanel(SoftwareDefinition definition)
{
var panel = new BorderedTableLayoutPanel
@ -350,8 +370,8 @@ internal sealed class ConfigurationForm : Form
AutoSizeMode = AutoSizeMode.GrowAndShrink,
Dock = DockStyle.Top,
ColumnCount = 2,
Padding = new Padding(16, 14, 16, 14),
Margin = new Padding(0, 10, 0, 0),
Padding = new Padding(14, 12, 14, 12),
Margin = new Padding(0, 8, 0, 0),
BackColor = Color.FromArgb(248, 250, 252),
BorderColor = Color.FromArgb(226, 232, 240),
};
@ -374,15 +394,12 @@ internal sealed class ConfigurationForm : Form
panel.Controls.Add(path, 0, 1);
panel.SetColumnSpan(path, 2);
var status = CreateBodyLabel();
status.Anchor = AnchorStyles.Left;
status.Margin = new Padding(0, 3, 16, 0);
status.Dock = DockStyle.Fill;
status.Margin = new Padding(0, 3, 0, 0);
panel.Controls.Add(status, 0, 2);
panel.SetColumnSpan(status, 2);
var actions = CreateActions();
actions.Anchor = AnchorStyles.Right;
actions.Dock = DockStyle.None;
actions.AutoSizeMode = AutoSizeMode.GrowAndShrink;
actions.WrapContents = false;
actions.Margin = new Padding(18, 0, 0, 0);
actions.Margin = new Padding(0, 10, 0, 0);
var automatic = CreateButton("自动检测", 92);
Button? choose = null;
if (definition.PathKind != SoftwarePathKind.None)
@ -393,7 +410,8 @@ internal sealed class ConfigurationForm : Form
var install = CreateButton("安装 / 更新环境", 150, primary: true);
actions.Controls.Add(automatic);
actions.Controls.Add(install);
panel.Controls.Add(actions, 1, 2);
panel.Controls.Add(actions, 0, 3);
panel.SetColumnSpan(actions, 2);
var controls = new SoftwareControls(path, status, automatic, choose, install);
softwareControls.Add(definition.Id, controls);
automatic.Click += (_, _) => ResetSoftwareLocation(definition);
@ -563,7 +581,7 @@ internal sealed class ConfigurationForm : Form
{
capabilitySummary.Text = currentConfig is null
? "注册后启用"
: string.Join("\n", adapters.All.Select(FormatAdapterStatus));
: FormatCapabilitySummary(adapters.All);
}
private void CopyDiagnostics()
@ -954,21 +972,20 @@ internal sealed class ConfigurationForm : Form
}
}
private static TableLayoutPanel CreateCard()
private static TableLayoutPanel CreateSection()
{
var card = new BorderedTableLayoutPanel
var section = new TableLayoutPanel
{
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink,
Dock = DockStyle.Top,
ColumnCount = 1,
Padding = new Padding(22, 20, 22, 20),
Margin = new Padding(0, 0, 0, 16),
Padding = new Padding(4, 16, 4, 12),
Margin = new Padding(0),
BackColor = Color.White,
BorderColor = Color.FromArgb(226, 232, 240),
};
card.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
return card;
section.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
return section;
}
private static TableLayoutPanel AddTab(TabControl tabs, string text)
@ -977,8 +994,8 @@ internal sealed class ConfigurationForm : Form
{
Text = text,
AutoScroll = true,
BackColor = Color.FromArgb(246, 248, 252),
Padding = new Padding(18, 20, 18, 20),
BackColor = Color.White,
Padding = new Padding(12, 0, 12, 0),
UseVisualStyleBackColor = false,
};
var content = new TableLayoutPanel
@ -1019,7 +1036,6 @@ internal sealed class ConfigurationForm : Form
AutoSize = true,
Dock = DockStyle.Fill,
ForeColor = Color.FromArgb(71, 85, 105),
MaximumSize = new Size(ContentWidth - 40, 0),
Margin = new Padding(0, 2, 0, 5),
};
@ -1299,6 +1315,12 @@ internal sealed class ConfigurationForm : Form
Button? Choose,
Button Install);
private sealed record CapabilitySummaryEntry(
string SoftwareName,
string? SoftwareVersion,
string CapabilityName,
bool Ready);
private sealed class BorderedTableLayoutPanel : TableLayoutPanel
{
internal Color BorderColor = Color.FromArgb(226, 232, 240);
@ -1325,10 +1347,22 @@ internal sealed class ConfigurationForm : Form
{
internal NavigationTabControl()
{
Appearance = TabAppearance.FlatButtons;
DrawMode = TabDrawMode.OwnerDrawFixed;
HotTrack = true;
SetStyle(ControlStyles.OptimizedDoubleBuffer, true);
}
protected override void OnResize(EventArgs eventArgs)
{
base.OnResize(eventArgs);
if (TabCount == 0)
{
return;
}
ItemSize = new Size(Math.Clamp(ClientSize.Width / TabCount, 96, 140), 38);
}
protected override void OnDrawItem(DrawItemEventArgs eventArgs)
{
var selected = eventArgs.Index == SelectedIndex;

View File

@ -19,6 +19,7 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
private readonly JobOutputUploader outputUploader = new(config);
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 =
[
@ -88,6 +89,19 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
}
}
internal void CancelActiveJobsForExit()
{
forcedStop.Cancel();
foreach (var job in jobInbox.ReadRecoverableJobs().Where(item => item.Terminal is null))
{
adapters.Find(job.Capability)?.Cancel(job.JobId);
if (!jobPipelines.ContainsKey(job.JobId))
{
WriteExitCancellation(job);
}
}
}
private async Task ConnectOnceAsync(CancellationToken cancellationToken)
{
using var socket = new ClientWebSocket();
@ -400,7 +414,7 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
if (job.Terminal is null)
{
jobInbox.WriteState(job, "downloading_inputs", 0, "正在下载并校验输入文件");
await inputDownloader.DownloadAsync(job, CancellationToken.None);
await inputDownloader.DownloadAsync(job, forcedStop.Token);
var afterDownload = jobInbox.ReadRecoverableJobs()
.Single(item => item.JobId == job.JobId);
if (afterDownload.Terminal is JsonElement cancelledTerminal)
@ -492,6 +506,20 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
? "结果已上传并由云端确认"
: "预览已上传,工程保存在本机工作区");
}
catch (OperationCanceledException) when (forcedStop.IsCancellationRequested)
{
var current = jobInbox.ReadRecoverableJobs()
.Single(item => item.JobId == job.JobId);
if (current.Terminal is null)
{
WriteExitCancellation(current);
}
if (jobInbox.ReadRecoverableJobs()
.Single(item => item.JobId == job.JobId).Terminal is JsonElement terminal)
{
await TrySendAsync(socket, "job_terminal", terminal);
}
}
catch (Exception exception) when (
exception is HttpRequestException
or IOException
@ -527,6 +555,13 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
}
}
private void WriteExitCancellation(RecoverableJob job)
{
const string detail = "Cancelled by the local administrator while exiting the node.";
jobInbox.WriteTerminal(job, "cancelled", "NODE_EXIT_CANCELLED", detail);
jobInbox.WriteState(job, "cancelled", 0, "本机管理员退出节点并取消了任务");
}
private async Task TrySendTerminalAsync(ClientWebSocket socket, RecoverableJob job)
{
var terminal = jobInbox.ReadRecoverableJobs()

View File

@ -6,9 +6,12 @@ internal sealed class TrayApplicationContext : ApplicationContext
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)
@ -31,7 +34,9 @@ internal sealed class TrayApplicationContext : ApplicationContext
menu.Items.Add("打开配置", null, (_, _) => ShowConfiguration());
menu.Items.Add("立即重连", null, (_, _) => RestartConnection());
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("退出", null, (_, _) => ExitNode());
exitItem = new ToolStripMenuItem("退出");
exitItem.Click += async (_, _) => await ExitNodeAsync();
menu.Items.Add(exitItem);
tray = new NotifyIcon
{
@ -95,7 +100,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
private void StartConnection()
{
if (config is null || connectionTask is { IsCompleted: false })
if (exiting || config is null || connectionTask is { IsCompleted: false })
{
return;
}
@ -106,10 +111,11 @@ internal sealed class TrayApplicationContext : ApplicationContext
private async Task RunConnectionAsync(NodeConfig nodeConfig, CancellationToken cancellationToken)
{
var loop = new NodeConnectionLoop(nodeConfig, status => PostStatus(status));
connectionLoop = loop;
try
{
await new NodeConnectionLoop(nodeConfig, status => PostStatus(status))
.RunAsync(cancellationToken);
await loop.RunAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@ -123,6 +129,13 @@ internal sealed class TrayApplicationContext : ApplicationContext
{
PostStatus(NodeStatus.Create(NodeState.Offline, $"节点异常:{exception.Message}"));
}
finally
{
if (ReferenceEquals(connectionLoop, loop))
{
connectionLoop = null;
}
}
}
private void RestartConnection()
@ -203,9 +216,63 @@ internal sealed class TrayApplicationContext : ApplicationContext
form.Activate();
}
private void ExitNode()
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();
}