fix(windows-node): 显示专业软件任务标题
This commit is contained in:
parent
25445082b7
commit
a219f8a0c0
|
|
@ -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 固定路由到单一控制面实例。
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue