feat(software): show execution versions in job list
This commit is contained in:
parent
92caa2a2c4
commit
6de88c7b0c
|
|
@ -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。
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>` : ""}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue