diff --git a/CHANGELOG.md b/CHANGELOG.md index 478d403..673dae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ## Unreleased -- Windows Node 不再错误显示 Origin 运行状态中的中文;本机窗口与 zcbot 管理页会同时显示 Node、Adapter 及从本机程序读取的 Origin 软件版本。 +- Windows Node 不再错误显示 Origin 运行状态中的中文;本机窗口、zcbot 管理页和用户专业软件任务列表会显示 Node、Adapter 及从本机程序读取的 Origin 软件版本。 - Windows Node 的专业软件适配器现在可作为独立目录更新;后续扩展 Origin 绘图参数时,可只替换适配器并重启本机 Node,无需重装或替换 Node 程序,服务端契约更新也无需重启 zcbot。 diff --git a/core/software_jobs.py b/core/software_jobs.py index 524b497..212231e 100644 --- a/core/software_jobs.py +++ b/core/software_jobs.py @@ -27,6 +27,40 @@ class SoftwareJobError(Exception): pass +def _execution_runtime_snapshot(node: SoftwareNode | None, capability: str) -> dict: + if node is None: + return {} + runtime = node.runtime if isinstance(node.runtime, dict) else {} + capability_runtime = runtime.get("capability_runtime") + current = ( + capability_runtime.get(capability, {}) + if isinstance(capability_runtime, dict) + else {} + ) + if not isinstance(current, dict): + current = {} + if capability == "origin.plot@v2" and isinstance(runtime.get("origin"), dict): + current = {**runtime["origin"], **current} + values = { + "node_version": runtime.get("node_version") or getattr(node, "node_version", ""), + "adapter_version": current.get("adapter_version"), + "software": current.get("software"), + "software_version": current.get("software_version"), + } + return { + key: value.strip() + for key, value in values.items() + if isinstance(value, str) and value.strip() + } + + +def _job_execution_runtime(row: SoftwareJob) -> dict: + stored_metrics = getattr(row, "metrics", None) + metrics = stored_metrics if isinstance(stored_metrics, dict) else {} + runtime = metrics.get("execution_runtime") + return runtime if isinstance(runtime, dict) else {} + + def software_job_output_path(capability: str, output_id: str) -> str: """返回 capability Job 输出目录内的契约路径。""" try: @@ -54,6 +88,7 @@ def _job_dict(row: SoftwareJob) -> dict: "stage": row.stage, "progress": row.progress, "metrics": row.metrics, + "execution_runtime": _job_execution_runtime(row), "error": row.error, "artifact_manifest": row.artifact_manifest, "output_dir": f"{contract.output_namespace}/{row.job_id}", @@ -634,6 +669,12 @@ def respond_to_offer(node_id: UUID, *, accepted: bool, payload: dict) -> None: job.status = "dispatched" job.stage = "accepted" job.error = {} + snapshot = _execution_runtime_snapshot( + session.get(SoftwareNode, node_id), job.capability + ) + if snapshot: + existing_metrics = job.metrics if isinstance(job.metrics, dict) else {} + job.metrics = {**existing_metrics, "execution_runtime": snapshot} else: job.status = "queued" job.node_id = None @@ -670,7 +711,12 @@ def update_job_state(node_id: UUID, payload: dict) -> None: ) job.stage = stage job.progress = progress - job.metrics = metrics + execution_runtime = _job_execution_runtime(job) + job.metrics = { + key: value for key, value in metrics.items() if key != "execution_runtime" + } + if execution_runtime: + job.metrics["execution_runtime"] = execution_runtime if job.status == "running" and job.started_at is None: job.started_at = now diff --git a/tests/test_software_nodes.py b/tests/test_software_nodes.py index f186c38..f71b50d 100644 --- a/tests/test_software_nodes.py +++ b/tests/test_software_nodes.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib import unittest +from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 @@ -266,7 +267,12 @@ class SoftwareJobProtocolTests(unittest.TestCase): job = type("Job", (), {})() job.job_id = uuid4(); job.task_id = uuid4(); job.capability = "origin.plot@v2" job.request_digest = "c" * 64; job.node_id = uuid4(); job.status = "running" - job.stage = "software_running"; job.progress = 20; job.metrics = {}; job.error = {} + job.stage = "software_running"; job.progress = 20; job.metrics = { + "execution_runtime": { + "node_version": "0.2.0", "adapter_version": "0.8.0", + "software": "OriginPro", "software_version": "2024", + } + }; job.error = {} job.artifact_manifest = []; job.input_manifest = [{"key": "sample", "filename": "input.xlsx"}] job.request = {"operation": {"plot": {"title": "Test"}}, "outputs": [ {"key": "figure_png", "type": "figure", "format": "png"} @@ -279,6 +285,7 @@ class SoftwareJobProtocolTests(unittest.TestCase): self.assertEqual(results[0]["request_summary"]["display_name"], "Origin 科研绘图") self.assertEqual(results[0]["request_summary"]["formats"], ["png"]) self.assertEqual(results[0]["output_dir"], f"origin/{job.job_id}") + self.assertEqual(results[0]["execution_runtime"]["software_version"], "2024") def test_origin_request_is_canonical_and_rejects_extra_fields(self) -> None: request = { @@ -463,6 +470,50 @@ class SoftwareJobProtocolTests(unittest.TestCase): payload={"job_id": str(uuid4()), "lease_id": str(job.lease_id)}, ) + @patch("core.software_jobs.session_scope") + def test_accepted_job_snapshots_execution_versions(self, session_scope) -> None: + session = session_scope.return_value.__enter__.return_value + node_id = uuid4() + lease_id = uuid4() + digest = "d" * 64 + job = type("Job", (), {})() + job.node_id = node_id; job.lease_id = lease_id; job.status = "offered" + job.lease_expires_at = datetime.now(timezone.utc) + timedelta(minutes=1) + job.request_digest = digest; job.capability = "origin.plot@v2"; job.metrics = {} + node = type("Node", (), {})() + node.node_version = "0.1.0" + node.runtime = { + "node_version": "0.2.0", + "capability_runtime": {"origin.plot@v2": { + "adapter_version": "0.8.0", "health": "ready", + }}, + "origin": { + "software": "OriginPro", "software_version": "2024", + "adapter_version": "0.8.0", "health": "ready", + }, + } + session.execute.return_value.scalar_one_or_none.return_value = job + session.get.return_value = node + + respond_to_offer(node_id, accepted=True, payload={ + "job_id": str(uuid4()), "lease_id": str(lease_id), + "request_digest": digest, + }) + + self.assertEqual(job.metrics["execution_runtime"], { + "node_version": "0.2.0", "adapter_version": "0.8.0", + "software": "OriginPro", "software_version": "2024", + }) + + job.started_at = None + update_job_state(node_id, { + "job_id": str(uuid4()), "lease_id": str(lease_id), + "request_digest": digest, "stage": "software_running", + "progress": 10, "metrics": {"elapsed_seconds": 1}, + }) + self.assertEqual(job.metrics["elapsed_seconds"], 1) + self.assertEqual(job.metrics["execution_runtime"]["software_version"], "2024") + @patch("core.software_jobs.session_scope") def test_failed_delivery_only_abandons_matching_offer(self, session_scope) -> None: session = session_scope.return_value.__enter__.return_value diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py index fdd1eda..dd9f742 100644 --- a/tests/test_static_vendor.py +++ b/tests/test_static_vendor.py @@ -45,6 +45,9 @@ class StaticVendorTests(unittest.TestCase): self.assertIn("/cancel`", source) self.assertIn("分析结果", source) self.assertIn("refreshCurrentTaskFiles(job.task_id)", source) + self.assertIn("job.execution_runtime", source) + self.assertIn("执行版本未记录", source) + self.assertIn("版本:${escapeHtml(jobRuntimeText(job))}", source) self.assertIn( 'document.addEventListener("software-job-submitted", refreshSoftwareJobs)', source, diff --git a/web/static/js/software_jobs.js b/web/static/js/software_jobs.js index 14eba62..5a9ba00 100644 --- a/web/static/js/software_jobs.js +++ b/web/static/js/software_jobs.js @@ -163,6 +163,21 @@ function onListScroll(event) { if (list.scrollHeight - list.scrollTop - list.clientHeight < 160) loadMore(); } +function jobRuntimeText(job) { + const runtime = job.execution_runtime || {}; + const software = runtime.software === "OriginPro" + || job.capability?.startsWith("origin.") ? "Origin" : (runtime.software || "软件"); + const parts = [ + runtime.node_version ? `Node ${runtime.node_version}` : "", + runtime.adapter_version ? `${software} Adapter ${runtime.adapter_version}` : "", + runtime.software_version ? `${software} ${runtime.software_version}` : "", + ].filter(Boolean); + if (parts.length) return parts.join(" · "); + return ["queued", "offered"].includes(job.status) + ? "执行版本待节点确认" + : "执行版本未记录"; +} + function jobCard(job) { const summary = job.request_summary || {}; const inputs = Array.isArray(job.input) ? job.input : []; @@ -179,6 +194,7 @@ function jobCard(job) {
${icons.activity}${escapeHtml(detail)}${error ? ` · ${escapeHtml(error)}` : ""}
${active ? `
` : ""}
${icons.clock}${escapeHtml(job.task_name || "未命名对话")}${inputNames ? ` · ${escapeHtml(inputNames)}` : ""}${openedText ? ` · 开启于 ${escapeHtml(openedText)}` : ""}
+
版本:${escapeHtml(jobRuntimeText(job))}
${job.status === "succeeded" ? `` : ""} diff --git a/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs b/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs index 84036e3..c631a54 100644 --- a/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs +++ b/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs @@ -495,6 +495,8 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action? && !item.HasActiveJobs ? 1 : 0; return new { + software = runtime.Software, + software_version = runtime.SoftwareVersion, adapter_version = item.AdapterVersion, features = item.Features, available_slots = slots,