diff --git a/CHANGELOG.md b/CHANGELOG.md index 0163ce7..1b42baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Unreleased +- 软件作业列表不再因历史任务使用的旧能力版本已经下线而整体消失;旧任务仍可查看,列表请求失败时页面也会明确提示刷新重试。 + - ANSYS 新增几何检查能力,可在求解前返回实体、面、尺寸、法向、面积、已有选择集和预览;静力分析现在可按坐标轴端面或指定平面自动建立固定与加载区域,并在数量不符时停止,结果中同时保留实际选区及预览。 - Origin 专业软件任务完成后会把轻量预览图回传到当前任务文件夹,并在图片下方显示可预览、下载的文件入口;预览图仍不会自动登记为正式产物,只有明确导出时才获得稳定产物身份。 diff --git a/core/software_jobs.py b/core/software_jobs.py index dfe6502..2d40427 100644 --- a/core/software_jobs.py +++ b/core/software_jobs.py @@ -14,6 +14,7 @@ from sqlalchemy.exc import IntegrityError from core.software_contracts import ( SoftwareContractError, get_contract, + get_contracts, node_supports_request, ) from core.storage.engine import session_scope @@ -86,12 +87,20 @@ def _canonical_request(capability: str, request: dict) -> tuple[dict, str]: def _job_dict(row: SoftwareJob) -> dict: - contract = get_contract(row.capability) + # 历史 Job 是事实记录,不应因当前可执行合同下线而无法读取。合同只用于补充 + # 展示信息;不存在时从持久化字段生成保守的兼容视图。 + contract = get_contracts().get(row.capability) + workspace_id = getattr(row, "workspace_id", None) + output_namespace = ( + contract.output_namespace + if contract is not None + else _historical_output_namespace(row.capability) + ) return { "job_id": str(row.job_id), "task_id": str(row.task_id), "workspace_id": ( - str(row.workspace_id) if getattr(row, "workspace_id", None) else None + str(workspace_id) if workspace_id else None ), "source_job_id": ( str(row.source_job_id) if getattr(row, "source_job_id", None) else None @@ -114,8 +123,8 @@ def _job_dict(row: SoftwareJob) -> dict: "followup_status": getattr(row, "followup_status", "none"), "output_dir": ( None - if contract.workspace is not None and getattr(row, "workspace_id", None) - else f"{contract.output_namespace}/{row.job_id}" + if workspace_id or output_namespace is None + else f"{output_namespace}/{row.job_id}" ), "created_at": row.created_at.isoformat() if row.created_at else None, "started_at": row.started_at.isoformat() if row.started_at else None, @@ -188,7 +197,32 @@ def list_jobs( def _request_summary(job: SoftwareJob) -> dict: - return get_contract(job.capability).summarize(job.request) + contract = get_contracts().get(job.capability) + if contract is not None: + return contract.summarize(job.request) + request = job.request if isinstance(job.request, dict) else {} + outputs = request.get("outputs") + operation = request.get("operation") + title = "" + if isinstance(operation, dict): + for value in operation.values(): + if isinstance(value, dict) and isinstance(value.get("title"), str): + title = value["title"] + break + return { + "display_name": job.capability, + "title": title, + "formats": [ + item.get("format") + for item in outputs if isinstance(item, dict) and item.get("format") + ] if isinstance(outputs, list) else [], + } + + +def _historical_output_namespace(capability: str) -> str | None: + """从版本化 capability 推导旧式结果目录;仅接受安全的首段名称。""" + namespace = capability.partition(".")[0] + return namespace if re.fullmatch(r"[a-z][a-z0-9_-]{0,31}", namespace) else None def request_job_cancel(user_id: UUID, job_id: UUID) -> tuple[dict, dict | None]: diff --git a/tests/frontend_files.test.mjs b/tests/frontend_files.test.mjs index 4b6a7fd..007f525 100644 --- a/tests/frontend_files.test.mjs +++ b/tests/frontend_files.test.mjs @@ -45,6 +45,7 @@ test("software jobs share the embed-safe right workspace", () => { assert.doesNotMatch(pageHtml, /id="software-job-panel"/); assert.match(layoutJs, /setRightPanelTab/); assert.match(jobsJs, /unreadTerminal/); + assert.match(jobsJs, /作业列表加载失败,请点击刷新重试/); }); test("mobile navigation distinguishes list, files, and software jobs", () => { diff --git a/tests/test_software_nodes.py b/tests/test_software_nodes.py index e34337b..1d4e854 100644 --- a/tests/test_software_nodes.py +++ b/tests/test_software_nodes.py @@ -382,6 +382,33 @@ class SoftwareJobProtocolTests(unittest.TestCase): self.assertEqual(results[0]["output_dir"], f"origin/{job.job_id}") self.assertEqual(results[0]["execution_runtime"]["software_version"], "2024") + @patch("core.software_jobs.session_scope") + def test_job_list_keeps_history_after_contract_is_retired(self, session_scope) -> None: + session = session_scope.return_value.__enter__.return_value + job = type("Job", (), {})() + job.job_id = uuid4(); job.task_id = uuid4() + job.capability = "ansys.mechanical.static_structural@v1" + job.request_digest = "d" * 64; job.node_id = None; job.status = "succeeded" + job.stage = "terminal"; job.progress = 100; job.metrics = {}; job.error = {} + job.artifact_manifest = []; job.input_manifest = [] + job.request = { + "operation": {"analysis": {"title": "历史静力分析"}}, + "outputs": [{"key": "report", "format": "html"}], + } + job.created_at = None; job.started_at = None; job.terminal_at = None + session.execute.return_value.all.return_value = [(job, "历史仿真", "LAB-01")] + + results = list_jobs(uuid4(), limit=10) + + self.assertEqual(len(results), 1) + self.assertEqual( + results[0]["request_summary"]["display_name"], + "ansys.mechanical.static_structural@v1", + ) + self.assertEqual(results[0]["request_summary"]["title"], "历史静力分析") + self.assertEqual(results[0]["request_summary"]["formats"], ["html"]) + self.assertEqual(results[0]["output_dir"], f"ansys/{job.job_id}") + def test_origin_request_is_canonical_and_rejects_extra_fields(self) -> None: request = { "schema_version": 2, diff --git a/web/static/js/software_jobs.js b/web/static/js/software_jobs.js index 4e4b2bf..e8643b7 100644 --- a/web/static/js/software_jobs.js +++ b/web/static/js/software_jobs.js @@ -22,6 +22,7 @@ let jobs = []; let nextCursor = null; let loadingMore = false; let initialized = false; +let loadError = ""; const unreadTerminal = new Set(); const statusLabel = { @@ -85,6 +86,7 @@ export async function refreshSoftwareJobs() { if (!state.token) return; try { const data = await api("GET", pagePath()); + loadError = ""; const next = data.results || []; next.forEach((job) => { const previous = known.get(job.job_id); @@ -115,7 +117,10 @@ export async function refreshSoftwareJobs() { jobs = [...next, ...previousJobs.filter((job) => !firstPageIds.has(job.job_id))]; if (!hadLoadedMore) nextCursor = data.next_cursor; render(); - } catch (_) { /* 后台刷新失败静默,下轮恢复 */ } + } catch (_) { + loadError = "作业列表加载失败,请点击刷新重试"; + render(); + } schedule(); } @@ -172,9 +177,11 @@ function render() { function renderList() { const list = $("software-job-list"); - list.innerHTML = jobs.length + const errorNotice = loadError + ? `