feat(software): show execution versions in job list

This commit is contained in:
caoqianming 2026-08-14 16:08:30 +08:00
parent 92caa2a2c4
commit 6de88c7b0c
6 changed files with 121 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {
<div class="sj-sub">${icons.activity}<span>${escapeHtml(detail)}${error ? ` · ${escapeHtml(error)}` : ""}</span></div>
${active ? `<div class="sj-progress"><i style="width:${progress}%"></i></div>` : ""}
<div class="sj-meta">${icons.clock}${escapeHtml(job.task_name || "未命名对话")}${inputNames ? ` · ${escapeHtml(inputNames)}` : ""}${openedText ? ` · 开启于 ${escapeHtml(openedText)}` : ""}</div>
<div class="sj-meta">版本${escapeHtml(jobRuntimeText(job))}</div>
<div class="sj-actions">
<button class="small" data-job-action="open" data-task-id="${escapeHtml(job.task_id)}">${icons.open}打开对话</button>
${job.status === "succeeded" ? `<button class="small primary" data-job-action="analyze" data-task-id="${escapeHtml(job.task_id)}">${icons.analyze}分析结果</button>` : ""}

View File

@ -495,6 +495,8 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
&& !item.HasActiveJobs ? 1 : 0;
return new
{
software = runtime.Software,
software_version = runtime.SoftwareVersion,
adapter_version = item.AdapterVersion,
features = item.Features,
available_slots = slots,