fix(windows-node): 显示专业软件任务标题

This commit is contained in:
caoqianming 2026-08-27 09:14:00 +08:00
parent 25445082b7
commit a219f8a0c0
6 changed files with 61 additions and 7 deletions

View File

@ -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 版本、运行类型、入口和契约文件,并可声明 11440 分钟的单任务超时;旧 manifest 缺省时保持 30 分钟,版本与时限不编译进 EXE。Node 在心跳中上报实际加载合同的 SHA-256 和 Workspace 协议版本,服务端只向明确兼容的节点调度 Workspace Job。Host 另提供本机运维层:按 `runtime_id` 合并软件卡,允许管理员自动检测或选择预定义应用位置,并只按内置软件清单及固定 requirements 单独构建受管 runtimeUI 不接受任意安装命令任务请求仍不能影响解释器、应用路径或安装过程。Host 将已验证的位置以固定环境变量注入对应 WorkerWorker 的 `--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 会在下一次契约查询时获得新校验和工具 schemaNode 在启动时从本地目录获得能力、版本和入口,并在首次连接及每次心跳中上报全部已发现 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 固定路由到单一控制面实例。

View File

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

View File

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

View File

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

View File

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

View File

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