From a219f8a0c05923150bdd1617bd19dabe461ce086 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 27 Aug 2026 09:14:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(windows-node):=20=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E4=B8=93=E4=B8=9A=E8=BD=AF=E4=BB=B6=E4=BB=BB=E5=8A=A1=E6=A0=87?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DESIGN.md | 2 + core/software_jobs.py | 3 ++ tests/test_blender_adapter.py | 4 ++ tests/test_software_nodes.py | 5 ++ tests/test_windows_node_source.py | 3 ++ .../Zcbot.WindowsNode/JobInboxStore.cs | 51 ++++++++++++++++--- 6 files changed, 61 insertions(+), 7 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 8c211f9..6bb08b2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -473,6 +473,8 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB) 专业软件采用“共享能力契约 + 本机 adapter”边界。仓库根目录 `software-contracts/*.json` 是语言无关的声明式源码事实源,描述 capability、请求 JSON Schema、输入额度、输出 manifest、feature 与最低 adapter 版本;Core 按文件签名热加载契约,只负责身份、账本、调度、传输、摘要与最终发布,不包含 Origin 或其他软件的操作分支。热加载先完整校验新快照再原子切换,写入中或非法版本继续使用上一份有效快照。Windows Node 是稳定的可信宿主,只负责持久化 job 目录、下载/上传、恢复、取消,以及从程序目录 `adapters/*/adapter.json` 发现 adapter;发布过程把对应源码合同复制进各 adapter 目录,Host 只读取 manifest 同目录指向的这一份合同并做通用 JSON Schema 校验,不再读取共享合同目录或采用本地优先 fallback。这样完整 Node 包与独立 adapter 包具有相同部署单元,manifest、合同、Worker 和依赖必须整体更新,避免旧合同静默覆盖新合同。manifest 声明 capability、实际 adapter 版本、运行类型、入口和契约文件,并可声明 1–1440 分钟的单任务超时;旧 manifest 缺省时保持 30 分钟,版本与时限不编译进 EXE。Node 在心跳中上报实际加载合同的 SHA-256 和 Workspace 协议版本,服务端只向明确兼容的节点调度 Workspace Job。Host 另提供本机运维层:按 `runtime_id` 合并软件卡,允许管理员自动检测或选择预定义应用位置,并只按内置软件清单及固定 requirements 单独构建受管 runtime;UI 不接受任意安装命令,任务请求仍不能影响解释器、应用路径或安装过程。Host 将已验证的位置以固定环境变量注入对应 Worker,Worker 的 `--probe` 继续承担最终软件版本、SDK/COM 和执行门健康校验。Worker 可以是受管 Python 脚本或独立 EXE,进程只接收 job 目录,并通过 `state.json`、`terminal.json` 与固定输出目录交接;Python、COM 或某个 SDK 都不属于通用任务协议。当前 Origin、ANSYS 和 Blender adapter 使用 Python 只是实现选择。 +Core 在 Job offer 中附带由 capability 合同生成的 `request_summary`,供本机任务列表统一显示标题;Host 不解析各软件私有请求结构,旧落盘 Job 只做通用兼容读取。 + 扩展现有 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 协议。 Node 通过 `Authorization: Bearer` 与 `X-Node-Id` 建立 `/v1/software-nodes/connect` WebSocket。进程内 Connection Manager 保证同一节点单活,新连接关闭旧连接;`hello`/`heartbeat` 更新版本、容量、软件健康与最后在线时间。管理员禁用节点时先持久化禁用态,再关闭现有连接;断线收尾不得覆盖禁用态。当前单活只覆盖单 Web 进程,生产启用多实例前必须增加 Redis/PG fencing 或将 Node API 固定路由到单一控制面实例。 diff --git a/core/software_jobs.py b/core/software_jobs.py index 2d40427..7092524 100644 --- a/core/software_jobs.py +++ b/core/software_jobs.py @@ -841,6 +841,9 @@ def offer_next_job(node_ids: set[UUID]) -> dict | None: "capability": job.capability, "request_digest": job.request_digest, "request": job.request, + # Node 的本机任务列表只消费合同生成的通用摘要,不解析各软件 + # 私有 request 结构。旧 Node 会忽略这个新增字段。 + "request_summary": _request_summary(job), "workspace": ( { "workspace_id": str(job.workspace_id), diff --git a/tests/test_blender_adapter.py b/tests/test_blender_adapter.py index 4d6acf1..95794eb 100644 --- a/tests/test_blender_adapter.py +++ b/tests/test_blender_adapter.py @@ -138,6 +138,10 @@ class BlenderContractTests(unittest.TestCase): tuple(f"preview_{index:02d}" for index in range(1, 9)), ) self.assertEqual(contract.required_adapter_version(normalized), "1.0.0") + self.assertEqual( + contract.summarize(normalized)["title"], + "水泥实验室设备布置", + ) self.assertEqual( set(contract.expected_outputs(normalized)), { diff --git a/tests/test_software_nodes.py b/tests/test_software_nodes.py index 3a94bbd..86cc4a7 100644 --- a/tests/test_software_nodes.py +++ b/tests/test_software_nodes.py @@ -799,6 +799,11 @@ class SoftwareJobProtocolTests(unittest.TestCase): self.assertEqual(offer["node_id"], node.node_id) self.assertEqual(offer["payload"]["job_id"], str(jobs[1].job_id)) self.assertEqual(offer["payload"]["request"]["operation"]["plot"]["type"], "line") + self.assertEqual( + offer["payload"]["request_summary"]["display_name"], + "Origin 科研绘图", + ) + self.assertIn("title", offer["payload"]["request_summary"]) def test_input_download_rechecks_file_digest(self) -> None: source = ( diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py index 1b725e4..9201889 100644 --- a/tests/test_windows_node_source.py +++ b/tests/test_windows_node_source.py @@ -412,6 +412,9 @@ class WindowsNodeSourceTests(unittest.TestCase): 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("transfers.GetArrayLength() > 16", inbox) self.assertNotIn("transfers.GetArrayLength() is < 1 or > 16", inbox) self.assertIn('"input", key, filename', inbox) diff --git a/windows-node/Zcbot.WindowsNode/JobInboxStore.cs b/windows-node/Zcbot.WindowsNode/JobInboxStore.cs index 539bfac..8ed2aa8 100644 --- a/windows-node/Zcbot.WindowsNode/JobInboxStore.cs +++ b/windows-node/Zcbot.WindowsNode/JobInboxStore.cs @@ -94,13 +94,7 @@ internal sealed class JobInboxStore(string jobsDirectory) var acceptedAt = ReadDate(root, "accepted_at") ?? new DateTimeOffset(File.GetCreationTimeUtc(requestPath)); var capability = ReadString(root, "capability", "unknown"); - var title = "未命名任务"; - if (root.TryGetProperty("request", out var request) - && request.TryGetProperty("operation", out var operation) - && operation.TryGetProperty("plot", out var plot)) - { - title = ReadString(plot, "title", title); - } + var title = ReadDisplayTitle(root); var inputFilename = root.TryGetProperty("input_transfers", out var transfers) && transfers.ValueKind == JsonValueKind.Array ? string.Join(", ", transfers.EnumerateArray() @@ -226,6 +220,11 @@ internal sealed class JobInboxStore(string jobsDirectory) { return JobOfferResult.Reject("invalid_offer"); } + JsonElement? requestSummary = payload.TryGetProperty( + "request_summary", out var summaryValue) + && summaryValue.ValueKind == JsonValueKind.Object + ? summaryValue.Clone() + : null; var adapter = adapters.Find(capability); if (adapter is null || !adapter.ValidateRequest(request)) { @@ -266,6 +265,7 @@ internal sealed class JobInboxStore(string jobsDirectory) capability, accepted_at = DateTimeOffset.UtcNow, request, + request_summary = requestSummary, workspace = workspace is null ? null : new { workspace_id = workspace.WorkspaceId, @@ -293,6 +293,7 @@ internal sealed class JobInboxStore(string jobsDirectory) capability, accepted_at = DateTimeOffset.UtcNow, request, + request_summary = requestSummary, workspace = workspace is null ? null : new { workspace_id = workspace.WorkspaceId, @@ -411,6 +412,42 @@ 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