diff --git a/DESIGN.md b/DESIGN.md index 733df3a..272de4f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -460,11 +460,11 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB) 第一阶段以 `docs/windows-node-mvp-intranet.md` 为实现契约:Windows Node 只作为受控执行节点,通过出站 HTTP/WS 主动连接 zcbot;首批能力固定为 Origin 绘图。长期方案中的 mTLS、Service/DesktopRunner 双进程、完整租约与多节点调度暂不进入 MVP,但 URL path、Node ID、Bearer Header 和任务协议保留原位升级空间。 -云端控制面使用独立的 `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` 发现 adapter;Host 对请求只做通用 JSON Schema 校验,具体软件探测和二次语义校验属于 Worker。manifest 声明 capability、实际 adapter 版本、运行类型、入口和契约文件,并可声明 1–1440 分钟的单任务超时;旧 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 只是实现选择。 -扩展现有 capability 的 feature 时修改共享契约、对应 Worker 和测试,不修改 Core 调度、Job 生命周期或 Node EXE;增加新专业软件时新增契约与一个 adapter 目录。Cloud 会在下一次契约查询时获得新校验和工具 schema;Node 在启动时从本地目录获得能力、版本和入口。当前选择明确的运维边界:zcbot 契约可热更新,不要求重启;本机 adapter 更新时先退出托盘 Node,整体替换 adapter 目录后重新启动,不重装、不替换 Node EXE,也不建设自动更新平台。当前 Node 仍按整机单执行槽保守串行,未来只有真实并行软件需求出现时,才把 slot 账本升级为 per-capability 租约,而不改变 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 固定路由到单一控制面实例。 @@ -486,7 +486,7 @@ Recipe 是专业软件的声明式目标状态,不是任意脚本或逐次鼠 Job 完成后的对话闭环复用 `software_jobs` 账本,不增加通用事件表。提交时用 `completion_action=report|analyze` 区分“只报告产物”和“报告并分析”,未明确分析时默认 `report`;成功终态将 `followup_status` 置为 `pending`。分发器只在原 task 空闲时领取:`report` 直接写入带 artifact refs 的固定 assistant 消息,不产生模型费用;`analyze` 写入前端隐藏的内部完成事件,并复用 task 单活锁、持久消息和 SSE run 自动续跑 Agent。`pending/running/completed/failed` 状态使服务重启、对话繁忙和重复完成回执均不会造成重复回复;Job 中心的手动深入分析通过同一入口重新排入分析,而不是模拟点击或只预填输入框。 -ANSYS 第二能力第一阶段新增 `ansys.mechanical.static_structural@v1`,目标固定为 Windows 上的 Mechanical 2024 R2(revision 242)。契约只接受一份已登记 STEP/Parasolid/IGES 几何、显式单位、内置 Structural Steel、全局网格尺寸、Named Selection 固定支撑、力/压力和三类结果,不接受 Mechanical 工程、Python、APDL、Journal、URL 或本机路径;输出命名固定为 MECHDAT、汇总/结果表、应力/变形图、solver log 和 `.meta` 溯源。adapter 使用独立 `runtimes/ansys` Python 3.12 环境与 240 分钟 Worker 超时,不与 Origin 依赖混用。2024 R2 没有新版 PyMechanical 的进程内许可证管理,周期 probe 只做 v242 安装和绑定探测,真实许可证通过首次求解验收;在专用节点完成连续算例、取消、残留进程和许可证释放检查前,由机器级验收门保持 `health=unavailable`,且 capability 不默认加入注册码。 +ANSYS 第二能力第一阶段新增 `ansys.mechanical.static_structural@v1`,目标固定为 Windows 上的 Mechanical 2024 R2(revision 242)。契约只接受一份已登记 STEP/Parasolid/IGES 几何、显式单位、内置 Structural Steel、全局网格尺寸、Named Selection 固定支撑、力/压力和三类结果,不接受 Mechanical 工程、Python、APDL、Journal、URL 或本机路径;输出命名固定为 MECHDAT、汇总/结果表、应力/变形图、solver log 和 `.meta` 溯源。adapter 使用独立 `runtimes/ansys` Python 3.12 环境与 240 分钟 Worker 超时,不与 Origin 依赖混用。2024 R2 没有新版 PyMechanical 的进程内许可证管理,周期 probe 只做 v242 安装和绑定探测,真实许可证通过首次求解验收;adapter 会被节点自动发现和上报,但在专用节点完成连续算例、取消、残留进程和许可证释放检查前,由机器级验收门保持 `health=unavailable`,不会进入实际调度。 后续仍需实现 Token 轮换;不得以任意命令或脚本接口临时代替。当前 Job 中心采用轮询而非用户事件推送,单活与 offer 选择仍只覆盖单 Web 进程;生产启用多实例前必须增加 Redis/PG fencing 或固定路由到单一控制面实例。 diff --git a/PROGRESS.md b/PROGRESS.md index 5463032..b423f32 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -20,6 +20,8 @@ --- ## 已完成关键能力 +- **08-18 / Unreleased / Windows Node 能力自动同步**:注册码只建立节点身份,不再要求管理员选择 Origin/ANSYS 能力;Node 从 adapter registry 自动发现全部本机能力,并在首次连接及每次心跳上报,服务端以双方已有共享契约的能力覆盖节点调度列表,管理后台改为只读展示。现有节点无需清身份或重新注册,替换新版 Node 并重启一次即可同步。 + - **08-18 / Unreleased / Windows Node 多 runtime 安装修复**:修复统一 BAT 安装器在连续处理 Origin 与 ANSYS 时依赖跨子程序 `PYTHON_EXE/PYTHON_ARGS` 状态、第二个 venv 可能执行成空命令的问题;每个缺失 runtime 现在都直接用已验证的显式 Python 3.12 命令创建,不再共享可变命令状态。 - **08-18 / Unreleased / ANSYS Mechanical 2024 R2 Node 第一阶段**:新增非默认注册的 `ansys.mechanical.static_structural@v1`,以单几何、显式单位、Structural Steel、Named Selection 支撑/力/压力和受控结果建立声明式静力契约,固定 Worker 已完成 v242/PyMechanical probe、二次语义校验及真机验收门,尚未开放真实求解。Windows Node 打包改为自动收集多个 adapter 和共享契约,manifest 可声明每软件 1–1440 分钟超时且兼容旧版 30 分钟默认值,统一安装器为 Origin/ANSYS 创建隔离 Python 3.12 runtime;68 项专项 unittest、Ruff 致命规则与 .NET build 通过,全量 659 项仅 3 个既有数据库集成模块因测试库缺少 `users` 表未通过(另跳过 4 项),未连接 ANSYS 或数据库。 diff --git a/RUN.md b/RUN.md index 5e96e85..474398f 100644 --- a/RUN.md +++ b/RUN.md @@ -1100,7 +1100,7 @@ install-windows-node.bat 若 Python 未加入 PATH,可把绝对路径作为第一个参数,例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认解释器分别为 `%ProgramData%\Zcbot\WindowsNode\runtimes\origin\Scripts\python.exe` 与 `%ProgramData%\Zcbot\WindowsNode\runtimes\ansys\Scripts\python.exe`。如需使用其他受管解释器,使用机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON` 或 `ZCBOT_ADAPTER_ANSYS_PYTHON`;旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务和 runtime 集中保存在 `%ProgramData%\Zcbot\WindowsNode\`,不会因替换程序目录而丢失。运行时固定依赖见各 adapter 的 `requirements.txt`;任务请求无权选择解释器、脚本或路径。Origin Worker 支持 CSV/XLSX/JSON 输入及 OPJU/PNG/SVG/PDF 输出,成功产物发布到 `origin//`,plot spec 与 provenance 位于其 `.meta/`;上传中断会在重连时幂等续传。 -ANSYS 第一阶段能力为 `ansys.mechanical.static_structural@v1`,只面向 Mechanical 2024 R2(revision 242),且注册码必须由管理员显式允许该 capability。当前交付先完成契约、安装探测、请求语义校验和执行安全门,默认 probe 返回不可用;在目标机器完成真实许可证与基准算例验收前,不得设置 `ZCBOT_ANSYS_242_VALIDATED=1`。验收至少包括:一个带 `fixed_face/load_face` Named Selection 的 STEP/Parasolid 几何能够完成线性静力求解并导出 MECHDAT、汇总、结果表和应力/变形图;取消能终止完整进程树;连续 20 次执行无残留 Mechanical/solver 进程且许可证均释放。验收通过后才以机器级环境变量开启执行门并重启托盘 Node。当前 Worker 的真实求解实现仍待该专用节点联调,开启安全门不会绕过 `ANSYS_242_SOLVER_IMPLEMENTATION_PENDING`。 +ANSYS 第一阶段能力为 `ansys.mechanical.static_structural@v1`,只面向 Mechanical 2024 R2(revision 242)。Node 会从 `adapters/` 自动发现并在每次心跳上报全部能力,服务端管理页只读展示双方已有共享契约的能力,不需要管理员勾选,也不需要现有节点清身份或重新注册;更新 Node 后重启一次即可同步。当前交付先完成契约、安装探测、请求语义校验和执行安全门,默认 probe 返回不可用;在目标机器完成真实许可证与基准算例验收前,不得设置 `ZCBOT_ANSYS_242_VALIDATED=1`。验收至少包括:一个带 `fixed_face/load_face` Named Selection 的 STEP/Parasolid 几何能够完成线性静力求解并导出 MECHDAT、汇总、结果表和应力/变形图;取消能终止完整进程树;连续 20 次执行无残留 Mechanical/solver 进程且许可证均释放。验收通过后才以机器级环境变量开启执行门并重启托盘 Node。当前 Worker 的真实求解实现仍待该专用节点联调,开启安全门不会绕过 `ANSYS_242_SOLVER_IMPLEMENTATION_PENDING`。 Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动任务或完成后的自动分析约 4 秒刷新一次,空闲时降为约 30 秒;卡片优先显示状态、所属对话、输入输出、完成策略和耗时,Job ID、输出目录、执行节点及软件版本收在“任务详情”中。成功任务可查看结果目录、复制完整 Job ID、深入分析或重新分析。停止已派发任务是协作取消,状态先显示“正在停止”,Node 在线时立即接收,断线后在下次连接或心跳时重放。Agent 可调用 `software_capability_list`、`register_artifact`、`software_job_submit`、`software_job_status`、`software_job_revise` 和 `software_job_cancel`。Origin 输入必须是 artifact:已有 UUID 可直接提交,普通 task 文件先逐个用相对路径登记;登记不会发布聊天交付卡片。提交工具接收 `inputs`、`operation`、`outputs` 和可选的 `completion_action=report|analyze`:只要求生成或保存时用默认 `report`,任务成功后平台向原对话写入固定产物报告;明确要求解释、总结或结论时用 `analyze`,平台在原 task 空闲后自动续跑 Agent。两者均不阻塞提交轮次。请求支持 1–16 个输入、跨输入系列和多个显式输出;`plot.canvas` 可指定毫米画布,轴可指定范围、步长、尺度、刻度角度/字号、标题字号和网格,`legend` 可控制显隐、位置和字号,`series[].style` 可控制颜色、线宽/线型、点型/点大小和透明度。所有排版字段可选,旧请求保持默认样式。成功状态提供 `output_dir`,正式输出的 artifact 带 `software_job_id`,供结果卡和产物详情展示来源。Node 输出上传的逐任务诊断日志位于 `%ProgramData%\Zcbot\WindowsNode\jobs\\logs\node-output-upload.log`;日志包含上传阶段、产物文件名、重试次数和 Windows `HRESULT`,单文件达到 1 MiB 后轮转一份 `.1`,不记录 Node Token 或认证请求头。 diff --git a/core/software_nodes.py b/core/software_nodes.py index 2b8aea5..6e8c0ea 100644 --- a/core/software_nodes.py +++ b/core/software_nodes.py @@ -10,7 +10,7 @@ from uuid import UUID, uuid4 import bcrypt from sqlalchemy import select -from core.software_contracts import default_capabilities, supported_capabilities +from core.software_contracts import supported_capabilities from core.storage.engine import session_scope from core.storage.models import SoftwareNode, SoftwareNodeEnrollment @@ -44,7 +44,9 @@ def create_enrollment( capabilities: list[str] | None = None, ttl_seconds: int = 600, ) -> dict: - allowed = list(dict.fromkeys(capabilities or default_capabilities())) + allowed = list(dict.fromkeys( + capabilities if capabilities is not None else sorted(supported_capabilities()) + )) if not allowed or any(item not in supported_capabilities() for item in allowed): raise SoftwareNodeError("unsupported capability") if not 60 <= ttl_seconds <= 3600: @@ -157,6 +159,13 @@ def update_node_runtime(node_id: UUID, *, status: str, runtime: dict) -> None: raise SoftwareNodeError("node is disabled or missing") node.status = status node.runtime = runtime + reported = runtime.get("installed_capabilities", runtime.get("capabilities", [])) + if isinstance(reported, list): + supported = supported_capabilities() + node.capabilities = list(dict.fromkeys( + item for item in reported + if isinstance(item, str) and item in supported + )) node.last_seen_at = datetime.now(timezone.utc) diff --git a/tests/test_software_nodes.py b/tests/test_software_nodes.py index ea680f5..4a8a089 100644 --- a/tests/test_software_nodes.py +++ b/tests/test_software_nodes.py @@ -32,7 +32,9 @@ from core.software_nodes import ( _enrollment_digest, _hash_secret, _verify_secret, + create_enrollment, delete_node, + update_node_runtime, ) from web.routers.software_nodes import NodeConnectionManager, _bearer @@ -68,6 +70,47 @@ class SoftwareNodeSecurityTests(unittest.TestCase): ) self.assertIn('code=4003, reason="invalid node credentials"', rejection) + @patch("core.software_nodes.session_scope") + def test_enrollment_allows_every_server_supported_capability_by_default( + self, session_scope + ) -> None: + session = session_scope.return_value.__enter__.return_value + + result = create_enrollment(uuid4()) + + enrollment = session.add.call_args.args[0] + self.assertEqual( + set(enrollment.allowed_capabilities), + {"origin.plot@v2", "ansys.mechanical.static_structural@v1"}, + ) + self.assertEqual(set(result["capabilities"]), set(enrollment.allowed_capabilities)) + + @patch("core.software_nodes.session_scope") + def test_heartbeat_replaces_capabilities_with_auto_discovered_adapters( + self, session_scope + ) -> None: + session = session_scope.return_value.__enter__.return_value + node = type("Node", (), {"status": "offline", "capabilities": ["origin.plot@v2"]})() + session.get.return_value = node + runtime = { + "installed_capabilities": [ + "origin.plot@v2", + "ansys.mechanical.static_structural@v1", + "future.unsupported@v1", + "origin.plot@v2", + ] + } + + update_node_runtime(uuid4(), status="online", runtime=runtime) + + self.assertEqual(node.status, "online") + self.assertIs(node.runtime, runtime) + self.assertEqual( + node.capabilities, + ["origin.plot@v2", "ansys.mechanical.static_structural@v1"], + ) + self.assertIsNotNone(node.last_seen_at) + class SoftwareNodeConnectionTests(unittest.IsolatedAsyncioTestCase): async def test_new_connection_replaces_old_without_removing_new(self) -> None: diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py index 4595e95..c97188e 100644 --- a/tests/test_static_vendor.py +++ b/tests/test_static_vendor.py @@ -68,7 +68,8 @@ class StaticVendorTests(unittest.TestCase): self.assertIn('id="node-enrollment-modal" class="modal"', html) self.assertIn("生成 Windows Node 注册码", html) self.assertIn('"/v1/admin/software-node-enrollments"', admin_js) - self.assertIn('capabilities: ["origin.plot@v2"]', admin_js) + self.assertNotIn('capabilities: ["origin.plot@v2"]', admin_js) + self.assertIn("节点能力由客户端自动发现并上报", html) self.assertIn("runtime.capability_runtime", admin_js) self.assertIn("item.adapter_version", admin_js) self.assertIn("item.software_version", admin_js) diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py index c58d0ca..c98a279 100644 --- a/tests/test_windows_node_source.py +++ b/tests/test_windows_node_source.py @@ -35,6 +35,18 @@ class WindowsNodeSourceTests(unittest.TestCase): ): self.assertIn(marker, source) + def test_runtime_auto_reports_all_discovered_adapter_capabilities(self) -> None: + connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8") + config_store = (PROJECT / "NodeConfigStore.cs").read_text(encoding="utf-8") + self.assertIn( + "var installedCapabilities = adapters.All.Select(item => item.Capability).ToArray()", + connection, + ) + self.assertIn("capabilities = installedCapabilities", connection) + self.assertIn("installed_capabilities = installedCapabilities", connection) + self.assertNotIn("config.Capabilities.Contains", connection) + self.assertNotIn("InstalledCapabilities.Contains", config_store) + def test_node_does_not_expose_arbitrary_execution_primitives(self) -> None: source = "\n".join( path.read_text(encoding="utf-8") diff --git a/web/schemas.py b/web/schemas.py index 086e48f..f8c4f43 100644 --- a/web/schemas.py +++ b/web/schemas.py @@ -6,7 +6,7 @@ from uuid import UUID from pydantic import BaseModel, Field -from core.software_contracts import default_capabilities +from core.software_contracts import default_capabilities, supported_capabilities class TaskCreateRequest(BaseModel): @@ -120,7 +120,7 @@ class ExternalSystemCredentialsRequest(BaseModel): class SoftwareEnrollmentCreateRequest(BaseModel): expected_name: str = "" - capabilities: list[str] = Field(default_factory=lambda: list(default_capabilities())) + capabilities: list[str] = Field(default_factory=lambda: sorted(supported_capabilities())) ttl_seconds: int = 600 diff --git a/web/static/admin.html b/web/static/admin.html index d9a424b..c3420ba 100644 --- a/web/static/admin.html +++ b/web/static/admin.html @@ -304,7 +304,7 @@
-

填写后,客户端注册时必须使用完全相同的节点名称。注册码有效期为 10 分钟。

+

填写后,客户端注册时必须使用完全相同的节点名称。注册码有效期为 10 分钟;节点能力由客户端自动发现并上报。

` - + `${rows}
节点状态运行环境版本能力最近心跳操作
`; + + `版本自动发现能力最近心跳操作${rows}`; $("node-enrollment-open").onclick = openNodeEnrollmentModal; $("s-windows-node").querySelectorAll("[data-node-toggle]").forEach(button => { button.onclick = () => toggleSoftwareNode(button); @@ -335,7 +335,6 @@ async function createNodeEnrollment(e) { try { const result = await apiSend("POST", "/v1/admin/software-node-enrollments", { expected_name: $("node-expected-name").value.trim(), - capabilities: ["origin.plot@v2"], ttl_seconds: 600, }); $("node-enrollment-code").textContent = result.enrollment_code || ""; diff --git a/windows-node/Zcbot.WindowsNode/NodeConfigStore.cs b/windows-node/Zcbot.WindowsNode/NodeConfigStore.cs index 0659522..5a7b635 100644 --- a/windows-node/Zcbot.WindowsNode/NodeConfigStore.cs +++ b/windows-node/Zcbot.WindowsNode/NodeConfigStore.cs @@ -55,13 +55,9 @@ internal sealed class NodeConfigStore(NodePaths paths) if (stored.NodeId == Guid.Empty || stored.InstallId == Guid.Empty || string.IsNullOrWhiteSpace(stored.NodeName) - || string.IsNullOrWhiteSpace(token) - || stored.Capabilities.Count == 0 - || stored.Capabilities.Any( - item => !NodeAdapterRegistry.InstalledCapabilities.Contains( - item, StringComparer.Ordinal))) + || string.IsNullOrWhiteSpace(token)) { - throw new NodeConfigurationException("Node configuration contains an invalid identity or capability."); + throw new NodeConfigurationException("Node configuration contains an invalid identity."); } return new NodeConfig( NodeUri.NormalizeServerUrl(stored.ServerUrl), diff --git a/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs b/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs index c631a54..e6ee6d6 100644 --- a/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs +++ b/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs @@ -484,7 +484,6 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? { var root = Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\"; var capabilityRuntime = adapters.All - .Where(item => config.Capabilities.Contains(item.Capability, StringComparer.Ordinal)) .ToDictionary( item => item.Capability, item => @@ -504,6 +503,7 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? detail = runtime.Detail, }; }); + var installedCapabilities = adapters.All.Select(item => item.Capability).ToArray(); // Keep the legacy top-level Origin payload during its deprecation window. var originAdapter = adapters.Find("origin.plot@v2"); var origin = originAdapter?.DetectRuntime() @@ -515,7 +515,8 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? node_name = config.NodeName, node_version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.1.0", os_version = RuntimeInformation.OSDescription, - capabilities = config.Capabilities, + capabilities = installedCapabilities, + installed_capabilities = installedCapabilities, available_slots = origin.Health == "ready" && !jobInbox.HasPendingJobs && !(originAdapter?.HasActiveJobs ?? false) ? 1 : 0,