fix(node): 统一 adapter 合同加载入口

This commit is contained in:
caoqianming 2026-08-19 14:02:06 +08:00
parent 72f7bd9107
commit 67ee7c244e
14 changed files with 100 additions and 45 deletions

View File

@ -8,6 +8,8 @@
## Unreleased ## Unreleased
- Windows Node 的每个专业软件 adapter 现在自带唯一运行时合同,不再受安装目录遗留共享合同影响;诊断信息会显示实际合同路径、摘要和 Workspace 协议状态,服务端也会阻止协议不匹配的任务反复派发。
- 修复专业软件持久工程首次建单失败后反复重试的问题;新任务会先建立工程空间再提交执行,并在数据库完整性异常时返回可诊断的错误。 - 修复专业软件持久工程首次建单失败后反复重试的问题;新任务会先建立工程空间再提交执行,并在数据库完整性异常时返回可诊断的错误。
- 专业软件任务失败或取消后会在原对话留下持久通知,页面离线、刷新或对话暂时忙碌时也不会错过结果。 - 专业软件任务失败或取消后会在原对话留下持久通知,页面离线、刷新或对话暂时忙碌时也不会错过结果。

View File

@ -462,7 +462,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
云端控制面使用独立的 `software_node_enrollments``software_nodes`,不复用用户外部系统连接。管理员创建的一次性注册码具有 128 bit 随机熵,数据库只保存 SHA-256 摘要;节点注册在行锁事务中校验有效期、预期名称和服务端支持的能力,成功后原子消费。注册码只建立节点身份,不要求管理员逐项授权专业软件能力。每个节点获得独立高熵 Token数据库只保存 bcrypt 强哈希,明文仅在注册响应出现一次。 云端控制面使用独立的 `software_node_enrollments``software_nodes`,不复用用户外部系统连接。管理员创建的一次性注册码具有 128 bit 随机熵,数据库只保存 SHA-256 摘要;节点注册在行锁事务中校验有效期、预期名称和服务端支持的能力,成功后原子消费。注册码只建立节点身份,不要求管理员逐项授权专业软件能力。每个节点获得独立高熵 Token数据库只保存 bcrypt 强哈希,明文仅在注册响应出现一次。
专业软件采用“共享能力契约 + 本机 adapter”边界。仓库根目录 `software-contracts/*.json` 是语言无关的声明式事实源,描述 capability、请求 JSON Schema、输入额度、输出 manifest、feature 与最低 adapter 版本Core 按文件签名热加载契约,只负责身份、账本、调度、传输、摘要与最终发布,不包含 Origin 或其他软件的操作分支。热加载先完整校验新快照再原子切换写入中或非法版本继续使用上一份有效快照。Windows Node 是稳定的可信宿主,只负责持久化 job 目录、下载/上传、恢复、取消,以及从程序目录 `adapters/*/adapter.json` 发现 adapterHost 对请求只做通用 JSON Schema 校验,具体软件探测和二次语义校验属于 Worker。manifest 声明 capability、实际 adapter 版本、运行类型、入口和契约文件,并可声明 11440 分钟的单任务超时;旧 manifest 缺省时保持 30 分钟,版本与时限不编译进 EXE。完整 Node 包按目录自动收集 adapter并把根目录契约复制到只读 `software-contracts/`;单 adapter 更新包仍可把同一契约副本放在 adapter 目录,加载时本地副本优先,两者都不形成源码第二事实源。Worker 可以是受管 Python 脚本或独立 EXE进程只接收 job 目录,并以 `--probe` 返回运行状态,通过 `state.json`、`terminal.json` 与固定输出目录交接Python、COM 或某个 SDK 都不属于通用协议。当前 Origin 和 ANSYS adapter 使用 Python 只是实现选择。 专业软件采用“共享能力契约 + 本机 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具体软件探测和二次语义校验属于 Worker。Worker 可以是受管 Python 脚本或独立 EXE进程只接收 job 目录,并以 `--probe` 返回运行状态,通过 `state.json`、`terminal.json` 与固定输出目录交接Python、COM 或某个 SDK 都不属于通用协议。当前 Origin 和 ANSYS adapter 使用 Python 只是实现选择。
扩展现有 capability 的 feature 时修改共享契约、对应 Worker 和测试,不修改 Core 调度、Job 生命周期或 Node EXE增加新专业软件时新增契约与一个 adapter 目录。Cloud 会在下一次契约查询时获得新校验和工具 schemaNode 在启动时从本地目录获得能力、版本和入口,并在首次连接及每次心跳中上报全部已发现 adapter。服务端用本次上报中自己具有共享契约的 capability 原子替换节点能力列表,未知能力不进入调度;管理端只读展示该结果,不保存人工能力选择。现有节点因此不需要清身份或重新注册,升级 Node 后重启一次即可在下一次心跳同步。当前选择明确的运维边界zcbot 契约可热更新,不要求重启;本机 adapter 更新时先退出托盘 Node整体替换 adapter 目录后重新启动,不重装、不替换 Node EXE也不建设自动更新平台。当前 Node 仍按整机单执行槽保守串行,未来只有真实并行软件需求出现时,才把 slot 账本升级为 per-capability 租约,而不改变 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 协议。

View File

@ -20,6 +20,8 @@
--- ---
## 已完成关键能力 ## 已完成关键能力
- **08-19 / Unreleased / Windows Node adapter 合同单入口**:仓库根合同继续作为源码事实源,完整 Node 包与独立 adapter 包均在发布时把合同放入对应 adapter 目录Node 移除共享 `software-contracts` fallback心跳与诊断新增实际合同摘要和 Workspace 协议状态,服务端只向明确支持协议的节点派发 Workspace Job避免遗留合同覆盖与确定性拒绝循环。
- **08-19 / Unreleased / Windows Node 持久 Workspace 与按需导出**:新增独立 `software_workspaces` 注册表,首个 Job 建立 Workspace、后续 Job 只从当前 head 在同一 home node 串行续作Node 以 `workspaces/<workspace_id>/current + rollback` 保存两代状态Origin adapter 1.0.0 可打开上一版 OPJU 再加工,失败自动回滚且不会按 Job 永久复制工程。Workspace 完成时只上传契约声明的预览缓存,预览不注册 Artifact用户明确要求下载或交付时才由 `software_job_export` 触发原节点上传并登记正式产物。专项 101 项 unittest 与 .NET build 通过;全量 672 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项),未连接或迁移生产数据库。 - **08-19 / Unreleased / Windows Node 持久 Workspace 与按需导出**:新增独立 `software_workspaces` 注册表,首个 Job 建立 Workspace、后续 Job 只从当前 head 在同一 home node 串行续作Node 以 `workspaces/<workspace_id>/current + rollback` 保存两代状态Origin adapter 1.0.0 可打开上一版 OPJU 再加工,失败自动回滚且不会按 Job 永久复制工程。Workspace 完成时只上传契约声明的预览缓存,预览不注册 Artifact用户明确要求下载或交付时才由 `software_job_export` 触发原节点上传并登记正式产物。专项 101 项 unittest 与 .NET build 通过;全量 672 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项),未连接或迁移生产数据库。
- **08-18 / Unreleased / Windows Node 能力自动同步**:注册码只建立节点身份,不再要求管理员选择 Origin/ANSYS 能力Node 从 adapter registry 自动发现全部本机能力,并在首次连接及每次心跳上报,服务端以双方已有共享契约的能力覆盖节点调度列表,管理后台改为只读展示。现有节点无需清身份或重新注册,替换新版 Node 并重启一次即可同步。 - **08-18 / Unreleased / Windows Node 能力自动同步**:注册码只建立节点身份,不再要求管理员选择 Origin/ANSYS 能力Node 从 adapter registry 自动发现全部本机能力,并在首次连接及每次心跳上报,服务端以双方已有共享契约的能力覆盖节点调度列表,管理后台改为只读展示。现有节点无需清身份或重新注册,替换新版 Node 并重启一次即可同步。

2
RUN.md
View File

@ -1137,7 +1137,7 @@ Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动
常用二维扩展单图 type 包括 `area`、`polar`、`pie`,均使用 `input/x/y`;其中 polar 的 X 是角度、Y 是半径pie 的 X 是扇区标签、Y 是数值,两者使用 Origin 原生坐标/标签系统且不接受笛卡尔 `x_axis/y_axis/z_axis` 参数。`stacked_area`、`stacked_bar` 与 `stacked_column` 一样至少提供两条 `input/x/y`。堆叠系列可以来自不同输入,但行数和逐行 X 值必须完全一致,否则 Worker 在启动绘图前以 `STACKED_PLOT_X_VALUES_MISMATCH` 拒绝,避免错位累计。统计图与这些扩展图暂不混入 `multi_panel.series[].kind` 常用二维扩展单图 type 包括 `area`、`polar`、`pie`,均使用 `input/x/y`;其中 polar 的 X 是角度、Y 是半径pie 的 X 是扇区标签、Y 是数值,两者使用 Origin 原生坐标/标签系统且不接受笛卡尔 `x_axis/y_axis/z_axis` 参数。`stacked_area`、`stacked_bar` 与 `stacked_column` 一样至少提供两条 `input/x/y`。堆叠系列可以来自不同输入,但行数和逐行 X 值必须完全一致,否则 Worker 在启动绘图前以 `STACKED_PLOT_X_VALUES_MISMATCH` 拒绝,避免错位累计。统计图与这些扩展图暂不混入 `multi_panel.series[].kind`
专业软件契约位于 `software-contracts/*.json`。zcbot 会在文件变化后校验并热加载完整契约快照正常修改契约不需要重启服务非法或尚未写完的文件不会替换上一份有效快照。Node Host 从 EXE 同级的 `adapters/*/adapter.json` 发现本机能力,通用校验只读取 manifest 指向的 JSON Schema完整包把所有共享契约放入 EXE 同级 `software-contracts/`adapter 独立更新包也可携带同名契约副本。manifest 可用 `worker_timeout_minutes` 为每种软件声明 11440 分钟的执行上限;旧 manifest 未声明时继续使用 30 分钟。Worker 可以是受管 Python 脚本或独立 EXE。 专业软件契约的源码事实源位于 `software-contracts/*.json`。zcbot 服务端会在文件变化后校验并热加载完整契约快照正常修改契约不需要重启服务非法或尚未写完的文件不会替换上一份有效快照。Node 发布时把对应事实源复制进 `adapters/<capability>/`,运行时只读取 manifest 同目录指向的合同,不存在共享目录 fallback因此完整 Node 包与独立 adapter 包具有相同结构,更新时必须整体替换 adapter 目录并重启 Node。Node 心跳上报实际合同 SHA-256 和 Workspace 协议版本,服务端不会把 Workspace Job 派给未明确声明支持的节点。manifest 可用 `worker_timeout_minutes` 为每种软件声明 11440 分钟的执行上限;旧 manifest 未声明时继续使用 30 分钟。Worker 可以是受管 Python 脚本或独立 EXE。
两个 JSON 的职责不同:`adapter.json` 只描述本机如何启动实际版本、runtime、入口和契约文件名`origin.plot.v2.json` 描述云端与本机共同遵守的业务请求/输出契约。前者不能替代后者;独立打包脚本只是把根目录的同一份业务契约复制进 adapter 交付目录,不维护第二份源码。 两个 JSON 的职责不同:`adapter.json` 只描述本机如何启动实际版本、runtime、入口和契约文件名`origin.plot.v2.json` 描述云端与本机共同遵守的业务请求/输出契约。前者不能替代后者;独立打包脚本只是把根目录的同一份业务契约复制进 adapter 交付目录,不维护第二份源码。

View File

@ -13,7 +13,6 @@ from typing import Any
from jsonschema import Draft202012Validator, FormatChecker from jsonschema import Draft202012Validator, FormatChecker
CONTRACT_ROOT = Path(__file__).resolve().parents[1] / "software-contracts" CONTRACT_ROOT = Path(__file__).resolve().parents[1] / "software-contracts"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -355,7 +354,10 @@ def node_supports_request(
if isinstance(advertised_features, list) and feature not in advertised_features: if isinstance(advertised_features, list) and feature not in advertised_features:
return False return False
required_version = contract.required_adapter_version(request) required_version = contract.required_adapter_version(request)
if contract.workspace is not None and version_at_least( if contract.workspace is not None:
if int(capability_runtime.get("workspace_protocol_version") or 0) < 1:
return False
if version_at_least(
contract.workspace.required_adapter_version, required_version contract.workspace.required_adapter_version, required_version
): ):
required_version = contract.workspace.required_adapter_version required_version = contract.workspace.required_adapter_version
@ -389,6 +391,11 @@ def node_available_slots(capability: str, runtime: dict[str, Any]) -> int:
contract = get_contract(capability) contract = get_contract(capability)
item = (runtime.get("capability_runtime") or {}).get(capability) item = (runtime.get("capability_runtime") or {}).get(capability)
if isinstance(item, dict) and item.get("health") == "ready": if isinstance(item, dict) and item.get("health") == "ready":
if (
contract.workspace is not None
and int(item.get("workspace_protocol_version") or 0) < 1
):
return 0
return max(0, int(item.get("available_slots") or 0)) return max(0, int(item.get("available_slots") or 0))
legacy_config = contract.legacy_runtime or {} legacy_config = contract.legacy_runtime or {}
if not legacy_config: if not legacy_config:

View File

@ -11,6 +11,7 @@ from core.software_contracts import (
DEFAULT_CAPABILITIES, DEFAULT_CAPABILITIES,
_ContractRegistry, _ContractRegistry,
get_contract, get_contract,
node_available_slots,
node_supports_request, node_supports_request,
version_at_least, version_at_least,
) )
@ -62,12 +63,22 @@ class SoftwareContractTests(unittest.TestCase):
"health": "ready", "health": "ready",
"available_slots": 1, "available_slots": 1,
"adapter_version": "1.0.0", "adapter_version": "1.0.0",
"workspace_protocol_version": 1,
"features": ["line", "heatmap"], "features": ["line", "heatmap"],
} }
} }
} }
self.assertTrue(node_supports_request(contract, _request("heatmap"), current_runtime)) self.assertTrue(node_supports_request(contract, _request("heatmap"), current_runtime))
self.assertEqual(node_available_slots("origin.plot@v2", current_runtime), 1)
self.assertFalse(node_supports_request(contract, _request("bar"), current_runtime)) self.assertFalse(node_supports_request(contract, _request("bar"), current_runtime))
current_runtime["capability_runtime"]["origin.plot@v2"].pop(
"workspace_protocol_version"
)
self.assertFalse(node_supports_request(contract, _request("line"), current_runtime))
self.assertEqual(node_available_slots("origin.plot@v2", current_runtime), 0)
current_runtime["capability_runtime"]["origin.plot@v2"][
"workspace_protocol_version"
] = 1
multi_panel = _request("line") multi_panel = _request("line")
multi_panel["operation"]["plot"] = { multi_panel["operation"]["plot"] = {

View File

@ -716,6 +716,7 @@ class SoftwareJobProtocolTests(unittest.TestCase):
node.capabilities = ["origin.plot@v2"] node.capabilities = ["origin.plot@v2"]
node.runtime = {"capability_runtime": {"origin.plot@v2": { node.runtime = {"capability_runtime": {"origin.plot@v2": {
"health": "ready", "available_slots": 1, "adapter_version": "1.0.0", "health": "ready", "available_slots": 1, "adapter_version": "1.0.0",
"workspace_protocol_version": 1,
"features": ["line"], "features": ["line"],
}}} }}}
jobs = [] jobs = []

View File

@ -203,8 +203,18 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn("..\\install-windows-node.bat", project) self.assertIn("..\\install-windows-node.bat", project)
self.assertIn("..\\adapters\\**\\*", project) self.assertIn("..\\adapters\\**\\*", project)
self.assertIn("adapters\\%(RecursiveDir)%(Filename)%(Extension)", project) self.assertIn("adapters\\%(RecursiveDir)%(Filename)%(Extension)", project)
self.assertIn("..\\..\\software-contracts\\*.json", project) self.assertIn("..\\..\\software-contracts\\origin.plot.v2.json", project)
self.assertIn("software-contracts\\%(Filename)%(Extension)", project) self.assertIn("adapters\\origin.plot@v2\\origin.plot.v2.json", project)
self.assertIn(
"..\\..\\software-contracts\\ansys.mechanical.static_structural.v1.json",
project,
)
self.assertIn(
"adapters\\ansys.mechanical.static_structural@v1\\"
"ansys.mechanical.static_structural.v1.json",
project,
)
self.assertNotIn("software-contracts\\%(Filename)%(Extension)", project)
self.assertFalse((ROOT / "install-windows-node.ps1").exists()) self.assertFalse((ROOT / "install-windows-node.ps1").exists())
self.assertFalse((ROOT / "install-origin-runtime.ps1").exists()) self.assertFalse((ROOT / "install-origin-runtime.ps1").exists())
self.assertFalse((ROOT / "install-startup.ps1").exists()) self.assertFalse((ROOT / "install-startup.ps1").exists())
@ -216,8 +226,11 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn('if /I "%~1"=="--self-contained"', script) self.assertIn('if /I "%~1"=="--self-contained"', script)
self.assertIn("dotnet publish", script) self.assertIn("dotnet publish", script)
self.assertIn('"install-windows-node.bat"', script) self.assertIn('"install-windows-node.bat"', script)
self.assertIn('for /r "!PUBLISH_DIR!\\adapters" %%F in (adapter.json)', script) self.assertIn('for /r "%PUBLISH_DIR%\\adapters" %%F in (adapter.json)', script)
self.assertIn('"!PUBLISH_DIR!\\software-contracts\\*.json"', script) self.assertIn('do if exist "%%~fF"', script)
self.assertIn("ADAPTER_CONTRACT_COUNT", script)
self.assertIn("Published adapter has no colocated contract", script)
self.assertNotIn('"!PUBLISH_DIR!\\software-contracts\\*.json"', script)
self.assertIn("tar.exe -a -c -f", script) self.assertIn("tar.exe -a -c -f", script)
self.assertIn("certutil.exe -hashfile", script) self.assertIn("certutil.exe -hashfile", script)
self.assertFalse((ROOT / "package-windows-node.ps1").exists()) self.assertFalse((ROOT / "package-windows-node.ps1").exists())
@ -275,6 +288,9 @@ class WindowsNodeSourceTests(unittest.TestCase):
'JsonSchema.Build(contractRoot.GetProperty("request_schema").Clone())', 'JsonSchema.Build(contractRoot.GetProperty("request_schema").Clone())',
adapters, adapters,
) )
self.assertIn("var contractPath = ResolveFile(root, manifest.Contract)", adapters)
self.assertNotIn("ResolveContractFile", adapters)
self.assertNotIn('AppContext.BaseDirectory, "software-contracts"', adapters)
self.assertNotIn("IsValidOriginRequest", inbox) self.assertNotIn("IsValidOriginRequest", inbox)
self.assertNotIn("IsValidPlot", inbox) self.assertNotIn("IsValidPlot", inbox)
self.assertIn('root.TryGetProperty("input_transfers"', inbox) self.assertIn('root.TryGetProperty("input_transfers"', inbox)
@ -359,6 +375,13 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn('"..\\software-contracts\\origin.plot.v2.json"', script) self.assertIn('"..\\software-contracts\\origin.plot.v2.json"', script)
self.assertNotIn("dotnet", script.lower()) 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)
self.assertIn("workspace_protocol_version", connection)
self.assertIn("contract_sha256", connection)
uploader = (PROJECT / "JobOutputUploader.cs").read_text(encoding="utf-8") uploader = (PROJECT / "JobOutputUploader.cs").read_text(encoding="utf-8")
self.assertIn('new AuthenticationHeaderValue("Bearer", config.NodeToken)', uploader) self.assertIn('new AuthenticationHeaderValue("Bearer", config.NodeToken)', uploader)
self.assertIn('DefaultRequestHeaders.Add("X-Node-Id"', uploader) self.assertIn('DefaultRequestHeaders.Add("X-Node-Id"', uploader)

View File

@ -2,7 +2,7 @@
内网 MVP 的 Windows 执行节点,目标运行环境为 Windows 11 Enterprise + .NET 10 SDK 10.0.303。仓库根目录 `global.json` 固定 SDK patch客户端只使用 .NET Windows Desktop Framework不依赖第三方 NuGet 包。 内网 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` 发现能力;Host 只按 manifest 指向的 JSON Schema 通用校验请求,软件探测、语义校验和执行都由 Worker 完成。Worker 可为受管 Python 脚本或独立 EXEmanifest 独立声明每种软件的任务超时及可选 Workspace 状态。当前安装包带 `origin.plot@v2` 和受真机验收门保护的 `ansys.mechanical.static_structural@v1` Python Worker但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录Origin 工程保存在 `workspaces/<workspace-id>/current`,只保留一份 `rollback`,完成时自动上传轻量预览,用户明确请求导出时才上传并发布所选正式产物。 当前实现托盘状态角标、配置与本机任务窗口、注册、DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连。Node Host 以语言无关的 job 目录协议负责持久化、下载、恢复、取消和上传,并从 EXE 同级 `adapters/*/adapter.json` 发现能力;每个 adapter 目录必须自带 manifest 指向的合同Node 不读取共享合同目录。Host 只按该合同的 JSON Schema 通用校验请求,软件探测、语义校验和执行都由 Worker 完成。Worker 可为受管 Python 脚本或独立 EXEmanifest 独立声明每种软件的任务超时及可选 Workspace 状态。当前安装包带 `origin.plot@v2` 和受真机验收门保护的 `ansys.mechanical.static_structural@v1` Python Worker但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录Origin 工程保存在 `workspaces/<workspace-id>/current`,只保留一份 `rollback`,完成时自动上传轻量预览,用户明确请求导出时才上传并发布所选正式产物。
在仓库根目录执行一条命令生成可分发 ZIP 在仓库根目录执行一条命令生成可分发 ZIP

View File

@ -309,7 +309,13 @@ internal sealed class ConfigurationForm : Form
.AppendLine($"Software: {runtime.Software} {runtime.SoftwareVersion ?? "unknown"}") .AppendLine($"Software: {runtime.Software} {runtime.SoftwareVersion ?? "unknown"}")
.AppendLine($"Health: {runtime.Health}") .AppendLine($"Health: {runtime.Health}")
.AppendLine($"Detail: {runtime.Detail}") .AppendLine($"Detail: {runtime.Detail}")
.AppendLine($"Runtime: {adapter.RuntimePath}"); .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 ( catch (Exception exception) when (
exception is IOException exception is IOException

View File

@ -1,4 +1,5 @@
using Json.Schema; using Json.Schema;
using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@ -14,6 +15,8 @@ internal interface INodeAdapter
string RunningDetail { get; } string RunningDetail { get; }
bool SupportsLocalAcceptance { get; } bool SupportsLocalAcceptance { get; }
string RuntimePath { get; } string RuntimePath { get; }
string ContractPath { get; }
string ContractSha256 { get; }
string? WorkspaceStateFilename { get; } string? WorkspaceStateFilename { get; }
IReadOnlyList<string> PreviewOutputIds { get; } IReadOnlyList<string> PreviewOutputIds { get; }
AdapterRuntimeStatus DetectRuntime(); AdapterRuntimeStatus DetectRuntime();
@ -52,6 +55,8 @@ internal sealed record NodeAdapterContract(
internal sealed record AdapterDescriptor( internal sealed record AdapterDescriptor(
string DirectoryPath, string DirectoryPath,
string EntrypointPath, string EntrypointPath,
string ContractPath,
string ContractSha256,
AdapterManifest Manifest, AdapterManifest Manifest,
NodeAdapterContract Contract) NodeAdapterContract Contract)
{ {
@ -92,8 +97,10 @@ internal sealed record AdapterDescriptor(
throw new InvalidDataException("Executable adapter must not declare runtime_id."); throw new InvalidDataException("Executable adapter must not declare runtime_id.");
} }
var contractPath = ResolveContractFile(root, manifest.Contract); var contractPath = ResolveFile(root, manifest.Contract);
using var contractDocument = JsonDocument.Parse(File.ReadAllBytes(contractPath)); var contractBytes = File.ReadAllBytes(contractPath);
var contractSha256 = Convert.ToHexString(SHA256.HashData(contractBytes)).ToLowerInvariant();
using var contractDocument = JsonDocument.Parse(contractBytes);
var contractRoot = contractDocument.RootElement; var contractRoot = contractDocument.RootElement;
var capability = RequiredString(contractRoot, "capability"); var capability = RequiredString(contractRoot, "capability");
if (!capability.Equals(manifest.Capability, StringComparison.Ordinal)) if (!capability.Equals(manifest.Capability, StringComparison.Ordinal))
@ -138,6 +145,8 @@ internal sealed record AdapterDescriptor(
return new AdapterDescriptor( return new AdapterDescriptor(
root, root,
entrypointPath, entrypointPath,
contractPath,
contractSha256,
manifest, manifest,
new NodeAdapterContract( new NodeAdapterContract(
capability, capability,
@ -163,30 +172,6 @@ internal sealed record AdapterDescriptor(
return resolved; return resolved;
} }
private static string ResolveContractFile(string adapterRoot, string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath)
|| Path.IsPathFullyQualified(relativePath)
|| Path.GetFileName(relativePath) != relativePath)
{
throw new InvalidDataException("Adapter contract path must be a relative filename.");
}
var local = Path.GetFullPath(Path.Combine(adapterRoot, relativePath));
if (local.StartsWith(adapterRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
&& File.Exists(local))
{
return local;
}
var sharedRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "software-contracts"));
var shared = Path.GetFullPath(Path.Combine(sharedRoot, relativePath));
if (!shared.StartsWith(sharedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|| !File.Exists(shared))
{
throw new InvalidDataException("Adapter contract is missing.");
}
return shared;
}
private static void RequireOnlyProperties(JsonElement value, params string[] names) private static void RequireOnlyProperties(JsonElement value, params string[] names)
{ {
if (value.ValueKind != JsonValueKind.Object) if (value.ValueKind != JsonValueKind.Object)
@ -269,6 +254,8 @@ internal sealed class ProcessNodeAdapter : INodeAdapter
"ansys.mechanical.static_structural@v1", StringComparison.Ordinal) "ansys.mechanical.static_structural@v1", StringComparison.Ordinal)
&& File.Exists(Path.Combine(Descriptor.DirectoryPath, "acceptance.py")); && File.Exists(Path.Combine(Descriptor.DirectoryPath, "acceptance.py"));
public string RuntimePath => runner.RuntimePath; public string RuntimePath => runner.RuntimePath;
public string ContractPath => Descriptor.ContractPath;
public string ContractSha256 => Descriptor.ContractSha256;
public string? WorkspaceStateFilename => Descriptor.Contract.WorkspaceStateFilename; public string? WorkspaceStateFilename => Descriptor.Contract.WorkspaceStateFilename;
public IReadOnlyList<string> PreviewOutputIds => Descriptor.Contract.PreviewOutputIds; public IReadOnlyList<string> PreviewOutputIds => Descriptor.Contract.PreviewOutputIds;

View File

@ -578,6 +578,8 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
software = runtime.Software, software = runtime.Software,
software_version = runtime.SoftwareVersion, software_version = runtime.SoftwareVersion,
adapter_version = item.AdapterVersion, adapter_version = item.AdapterVersion,
contract_sha256 = item.ContractSha256,
workspace_protocol_version = item.WorkspaceStateFilename is null ? 0 : 1,
features = item.Features, features = item.Features,
available_slots = slots, available_slots = slots,
health = runtime.Health, health = runtime.Health,

View File

@ -20,8 +20,13 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content> </Content>
<Content Include="..\..\software-contracts\*.json"> <Content Include="..\..\software-contracts\origin.plot.v2.json">
<Link>software-contracts\%(Filename)%(Extension)</Link> <Link>adapters\origin.plot@v2\origin.plot.v2.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="..\..\software-contracts\ansys.mechanical.static_structural.v1.json">
<Link>adapters\ansys.mechanical.static_structural@v1\ansys.mechanical.static_structural.v1.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content> </Content>

View File

@ -70,15 +70,24 @@ for %%F in (
) )
) )
set "ADAPTER_COUNT=0" set "ADAPTER_COUNT=0"
for /r "!PUBLISH_DIR!\adapters" %%F in (adapter.json) do set /a ADAPTER_COUNT+=1 for /r "%PUBLISH_DIR%\adapters" %%F in (adapter.json) do if exist "%%~fF" set /a ADAPTER_COUNT+=1
if !ADAPTER_COUNT! LSS 1 ( if !ADAPTER_COUNT! LSS 1 (
echo [ERR] Published package contains no adapters. echo [ERR] Published package contains no adapters.
goto :failed goto :failed
) )
if not exist "!PUBLISH_DIR!\software-contracts\*.json" ( set "ADAPTER_CONTRACT_ERROR=0"
echo [ERR] Published package contains no software contracts. for /r "%PUBLISH_DIR%\adapters" %%F in (adapter.json) do if exist "%%~fF" (
goto :failed set "ADAPTER_DIR=%%~dpF"
set "ADAPTER_CONTRACT_COUNT=0"
for %%C in ("!ADAPTER_DIR!*.json") do (
if exist "%%~fC" if /I not "%%~nxC"=="adapter.json" set /a ADAPTER_CONTRACT_COUNT+=1
)
if !ADAPTER_CONTRACT_COUNT! LSS 1 (
echo [ERR] Published adapter has no colocated contract: %%F
set "ADAPTER_CONTRACT_ERROR=1"
)
) )
if !ADAPTER_CONTRACT_ERROR! NEQ 0 goto :failed
tar.exe -a -c -f "!ARCHIVE_PATH!" -C "!OUTPUT_ROOT!" "!PACKAGE_NAME!" tar.exe -a -c -f "!ARCHIVE_PATH!" -C "!OUTPUT_ROOT!" "!PACKAGE_NAME!"
if errorlevel 1 ( if errorlevel 1 (