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 ## Unreleased
- Windows Node 不再错误显示 Origin 运行状态中的中文;本机窗口与 zcbot 管理页会同时显示 Node、Adapter 及从本机程序读取的 Origin 软件版本。 - Windows Node 不再错误显示 Origin 运行状态中的中文;本机窗口、zcbot 管理页和用户专业软件任务列表会显示 Node、Adapter 及从本机程序读取的 Origin 软件版本。
- Windows Node 的专业软件适配器现在可作为独立目录更新;后续扩展 Origin 绘图参数时,可只替换适配器并重启本机 Node无需重装或替换 Node 程序,服务端契约更新也无需重启 zcbot。 - Windows Node 的专业软件适配器现在可作为独立目录更新;后续扩展 Origin 绘图参数时,可只替换适配器并重启本机 Node无需重装或替换 Node 程序,服务端契约更新也无需重启 zcbot。

View File

@ -27,6 +27,40 @@ class SoftwareJobError(Exception):
pass 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: def software_job_output_path(capability: str, output_id: str) -> str:
"""返回 capability Job 输出目录内的契约路径。""" """返回 capability Job 输出目录内的契约路径。"""
try: try:
@ -54,6 +88,7 @@ def _job_dict(row: SoftwareJob) -> dict:
"stage": row.stage, "stage": row.stage,
"progress": row.progress, "progress": row.progress,
"metrics": row.metrics, "metrics": row.metrics,
"execution_runtime": _job_execution_runtime(row),
"error": row.error, "error": row.error,
"artifact_manifest": row.artifact_manifest, "artifact_manifest": row.artifact_manifest,
"output_dir": f"{contract.output_namespace}/{row.job_id}", "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.status = "dispatched"
job.stage = "accepted" job.stage = "accepted"
job.error = {} 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: else:
job.status = "queued" job.status = "queued"
job.node_id = None job.node_id = None
@ -670,7 +711,12 @@ def update_job_state(node_id: UUID, payload: dict) -> None:
) )
job.stage = stage job.stage = stage
job.progress = progress 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: if job.status == "running" and job.started_at is None:
job.started_at = now job.started_at = now

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import importlib import importlib
import unittest import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4 from uuid import uuid4
@ -266,7 +267,12 @@ class SoftwareJobProtocolTests(unittest.TestCase):
job = type("Job", (), {})() job = type("Job", (), {})()
job.job_id = uuid4(); job.task_id = uuid4(); job.capability = "origin.plot@v2" 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.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.artifact_manifest = []; job.input_manifest = [{"key": "sample", "filename": "input.xlsx"}]
job.request = {"operation": {"plot": {"title": "Test"}}, "outputs": [ job.request = {"operation": {"plot": {"title": "Test"}}, "outputs": [
{"key": "figure_png", "type": "figure", "format": "png"} {"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"]["display_name"], "Origin 科研绘图")
self.assertEqual(results[0]["request_summary"]["formats"], ["png"]) self.assertEqual(results[0]["request_summary"]["formats"], ["png"])
self.assertEqual(results[0]["output_dir"], f"origin/{job.job_id}") 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: def test_origin_request_is_canonical_and_rejects_extra_fields(self) -> None:
request = { request = {
@ -463,6 +470,50 @@ class SoftwareJobProtocolTests(unittest.TestCase):
payload={"job_id": str(uuid4()), "lease_id": str(job.lease_id)}, 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") @patch("core.software_jobs.session_scope")
def test_failed_delivery_only_abandons_matching_offer(self, session_scope) -> None: def test_failed_delivery_only_abandons_matching_offer(self, session_scope) -> None:
session = session_scope.return_value.__enter__.return_value session = session_scope.return_value.__enter__.return_value

View File

@ -45,6 +45,9 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn("/cancel`", source) self.assertIn("/cancel`", source)
self.assertIn("分析结果", source) self.assertIn("分析结果", source)
self.assertIn("refreshCurrentTaskFiles(job.task_id)", 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( self.assertIn(
'document.addEventListener("software-job-submitted", refreshSoftwareJobs)', 'document.addEventListener("software-job-submitted", refreshSoftwareJobs)',
source, source,

View File

@ -163,6 +163,21 @@ function onListScroll(event) {
if (list.scrollHeight - list.scrollTop - list.clientHeight < 160) loadMore(); 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) { function jobCard(job) {
const summary = job.request_summary || {}; const summary = job.request_summary || {};
const inputs = Array.isArray(job.input) ? job.input : []; 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> <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>` : ""} ${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">${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"> <div class="sj-actions">
<button class="small" data-job-action="open" data-task-id="${escapeHtml(job.task_id)}">${icons.open}打开对话</button> <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>` : ""} ${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; && !item.HasActiveJobs ? 1 : 0;
return new return new
{ {
software = runtime.Software,
software_version = runtime.SoftwareVersion,
adapter_version = item.AdapterVersion, adapter_version = item.AdapterVersion,
features = item.Features, features = item.Features,
available_slots = slots, available_slots = slots,