zcbot/core/software_jobs.py

907 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""专业软件任务的校验、幂等持久化和 offer 状态机。"""
from __future__ import annotations
import json
import re
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
from sqlalchemy import and_, desc, or_, select
from sqlalchemy.exc import IntegrityError
from core.software_contracts import (
SoftwareContractError,
get_contract,
node_supports_request,
)
from core.storage.engine import session_scope
from core.storage.models import Artifact, SoftwareJob, SoftwareNode, Task
OFFER_SECONDS = 60
MAX_OUTPUT_ARTIFACT_BYTES = 256 * 1024 * 1024
MAX_OUTPUT_TOTAL_BYTES = 512 * 1024 * 1024
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:
return get_contract(capability).output_spec(output_id).relative_path
except SoftwareContractError as exc:
raise SoftwareJobError(str(exc)) from exc
def _canonical_request(capability: str, request: dict) -> tuple[dict, str]:
try:
return get_contract(capability).normalize_request(request)
except SoftwareContractError as exc:
raise SoftwareJobError(str(exc)) from exc
def _job_dict(row: SoftwareJob) -> dict:
contract = get_contract(row.capability)
return {
"job_id": str(row.job_id),
"task_id": str(row.task_id),
"capability": row.capability,
"request_digest": row.request_digest,
"node_id": str(row.node_id) if row.node_id else None,
"status": row.status,
"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}",
"created_at": row.created_at.isoformat() if row.created_at else None,
"started_at": row.started_at.isoformat() if row.started_at else None,
"terminal_at": row.terminal_at.isoformat() if row.terminal_at else None,
}
def list_jobs(
user_id: UUID,
*,
task_id: UUID | None = None,
active_only: bool = False,
limit: int = 50,
before: tuple[datetime, UUID] | None = None,
) -> list[dict]:
"""列出用户的软件任务;用于全局 Job 中心和 Agent 查询。"""
# Web 分页会多取一条判断是否还有下一页;公开接口仍把页大小限制为 100。
limit = max(1, min(int(limit), 101))
with session_scope() as session:
statement = (
select(SoftwareJob, Task.name, SoftwareNode.name)
.join(Task, Task.task_id == SoftwareJob.task_id)
.outerjoin(SoftwareNode, SoftwareNode.node_id == SoftwareJob.node_id)
.where(SoftwareJob.user_id == user_id)
)
if task_id is not None:
statement = statement.where(SoftwareJob.task_id == task_id)
if active_only:
statement = statement.where(
SoftwareJob.status.in_(
{
"queued",
"offered",
"dispatched",
"running",
"disconnected",
"cancelling",
}
)
)
if before is not None:
before_created_at, before_job_id = before
statement = statement.where(
or_(
SoftwareJob.created_at < before_created_at,
and_(
SoftwareJob.created_at == before_created_at,
SoftwareJob.job_id < before_job_id,
),
)
)
rows = session.execute(
statement.order_by(
desc(SoftwareJob.created_at), desc(SoftwareJob.job_id)
).limit(limit)
).all()
results: list[dict] = []
for job, task_name, node_name in rows:
item = _job_dict(job)
item.update(
{
"task_name": task_name,
"node_name": node_name,
"input": job.input_manifest,
"request_summary": _request_summary(job),
}
)
results.append(item)
return results
def _request_summary(job: SoftwareJob) -> dict:
return get_contract(job.capability).summarize(job.request)
def request_job_cancel(user_id: UUID, job_id: UUID) -> tuple[dict, dict | None]:
"""持久化取消意图queued 直接终止,已分派任务返回 Node 消息。"""
now = datetime.now(timezone.utc)
with session_scope() as session:
job = session.execute(
select(SoftwareJob).where(
SoftwareJob.job_id == job_id,
SoftwareJob.user_id == user_id,
).with_for_update()
).scalar_one_or_none()
if job is None:
raise SoftwareJobError("job not found")
if job.status in {"succeeded", "failed", "cancelled"}:
return _job_dict(job), None
if job.status == "queued" or job.node_id is None or job.lease_id is None:
job.status = "cancelled"
job.stage = "terminal"
job.error = {"code": "USER_CANCELLED", "detail": "Cancelled before dispatch."}
job.terminal_at = now
return _job_dict(job), None
job.status = "cancelling"
job.stage = "cancel_requested"
payload = {
"job_id": str(job.job_id),
"lease_id": str(job.lease_id),
"request_digest": job.request_digest,
}
return _job_dict(job), {"node_id": job.node_id, "payload": payload}
def pending_node_cancellations(node_id: UUID) -> list[dict]:
"""节点重连或心跳时重放未确认的取消意图。"""
with session_scope() as session:
rows = session.execute(
select(SoftwareJob).where(
SoftwareJob.node_id == node_id,
SoftwareJob.status == "cancelling",
)
).scalars()
return [
{
"job_id": str(job.job_id),
"lease_id": str(job.lease_id),
"request_digest": job.request_digest,
}
for job in rows
if job.lease_id is not None
]
def create_job(
user_id: UUID,
task_id: UUID,
*,
idempotency_key: str,
capability: str,
request: dict,
) -> tuple[dict, bool]:
key = idempotency_key.strip()
if not key or len(key) > 200:
raise SoftwareJobError("idempotency_key must contain 1 to 200 characters")
try:
contract = get_contract(capability)
normalized, digest = contract.normalize_request(request)
except SoftwareContractError as exc:
raise SoftwareJobError(str(exc)) from exc
with session_scope() as session:
task = session.execute(
select(Task.task_id).where(Task.task_id == task_id, Task.user_id == user_id)
).first()
if task is None:
raise SoftwareJobError("task not found")
bindings = contract.input_bindings(normalized)
artifact_ids = [UUID(item["artifact_id"]) for item in bindings]
artifacts = session.execute(
select(Artifact).where(
Artifact.artifact_id.in_(set(artifact_ids)),
Artifact.user_id == user_id,
Artifact.status == "active",
)
).scalars().all()
artifacts_by_id = {artifact.artifact_id: artifact for artifact in artifacts}
input_manifest: list[dict] = []
total_input_bytes = 0
policy = contract.input_policy
allowed_suffixes = frozenset(policy.get("suffixes") or [])
max_input_bytes = int(policy.get("max_bytes") or 0)
for binding, artifact_id in zip(bindings, artifact_ids, strict=True):
artifact = artifacts_by_id.get(artifact_id)
if artifact is None:
raise SoftwareJobError("input artifact not found")
suffix = (
"." + artifact.current_path.rsplit(".", 1)[-1].lower()
if "." in artifact.current_path else ""
)
if suffix not in allowed_suffixes:
raise SoftwareJobError("input artifact type is not supported")
if (
artifact.size_bytes is None
or artifact.size_bytes < 0
or artifact.size_bytes > max_input_bytes
or not artifact.content_sha256
or len(artifact.content_sha256) != 64
):
raise SoftwareJobError("input artifact metadata is incomplete or too large")
total_input_bytes += artifact.size_bytes
item = {
"key": binding["key"],
"artifact_id": str(artifact.artifact_id),
"filename": artifact.current_path.replace("\\", "/").rsplit("/", 1)[-1],
"size_bytes": artifact.size_bytes,
"sha256": artifact.content_sha256,
}
if binding.get("selector") is not None:
item["selector"] = binding["selector"]
input_manifest.append(item)
if total_input_bytes > int(policy.get("max_total_bytes") or 0):
raise SoftwareJobError("job inputs exceed the total size limit")
existing = session.execute(
select(SoftwareJob).where(
SoftwareJob.user_id == user_id,
SoftwareJob.idempotency_key == key,
)
).scalar_one_or_none()
if existing is not None:
if (
existing.task_id != task_id
or existing.capability != capability
or existing.request_digest != digest
):
raise SoftwareJobError("idempotency key was already used for a different request")
return _job_dict(existing), False
row = SoftwareJob(
job_id=uuid4(),
user_id=user_id,
task_id=task_id,
idempotency_key=key,
capability=capability,
request=normalized,
request_digest=digest,
input_manifest=input_manifest,
status="queued",
stage="",
metrics={},
error={},
artifact_manifest=[],
)
try:
with session.begin_nested():
session.add(row)
session.flush()
return _job_dict(row), True
except IntegrityError:
existing = session.execute(
select(SoftwareJob).where(
SoftwareJob.user_id == user_id,
SoftwareJob.idempotency_key == key,
)
).scalar_one()
if (
existing.task_id != task_id
or existing.capability != capability
or existing.request_digest != digest
):
raise SoftwareJobError(
"idempotency key was already used for a different request"
)
return _job_dict(existing), False
def get_job(user_id: UUID, job_id: UUID) -> dict | None:
with session_scope() as session:
row = session.execute(
select(SoftwareJob).where(SoftwareJob.job_id == job_id, SoftwareJob.user_id == user_id)
).scalar_one_or_none()
return _job_dict(row) if row else None
def get_job_request(user_id: UUID, task_id: UUID, job_id: UUID) -> dict | None:
"""返回当前用户和任务内可供 Agent 修改的规范化请求。"""
with session_scope() as session:
request = session.execute(
select(SoftwareJob.request).where(
SoftwareJob.job_id == job_id,
SoftwareJob.user_id == user_id,
SoftwareJob.task_id == task_id,
)
).scalar_one_or_none()
if request is None:
return None
return deepcopy(request)
def revise_job(
user_id: UUID,
task_id: UUID,
source_job_id: UUID,
*,
idempotency_key: str,
operation: dict,
outputs: list[dict],
) -> tuple[dict, bool]:
"""复用源任务已登记输入,创建不可变的新任务。"""
with session_scope() as session:
source = session.execute(
select(SoftwareJob).where(
SoftwareJob.job_id == source_job_id,
SoftwareJob.user_id == user_id,
SoftwareJob.task_id == task_id,
)
).scalar_one_or_none()
if source is None:
raise SoftwareJobError("source software job not found")
capability = source.capability
source_request = source.request
inputs = deepcopy(source_request.get("inputs") or [])
schema_version = get_contract(capability).request_schema[
"properties"
]["schema_version"]["const"]
return create_job(
user_id,
task_id,
idempotency_key=idempotency_key,
capability=capability,
request={
"schema_version": schema_version,
"inputs": inputs,
"operation": operation,
"outputs": outputs,
},
)
def offer_next_job(node_ids: set[UUID]) -> dict | None:
"""选择最早可执行的 JobNode 组合,避免跨 capability 队首阻塞。"""
if not node_ids:
return None
now = datetime.now(timezone.utc)
with session_scope() as session:
expired = session.execute(
select(SoftwareJob)
.where(
SoftwareJob.status == "offered",
SoftwareJob.lease_expires_at <= now,
)
.with_for_update(skip_locked=True)
).scalars()
for item in expired:
item.status = "queued"
item.node_id = None
item.lease_id = None
item.lease_expires_at = None
busy_node_ids = set(
session.execute(
select(SoftwareJob.node_id).where(
SoftwareJob.node_id.is_not(None),
SoftwareJob.status.in_(
{"offered", "dispatched", "running", "disconnected", "cancelling"}
),
)
).scalars()
)
nodes = list(session.execute(
select(SoftwareNode)
.where(SoftwareNode.node_id.in_(node_ids), SoftwareNode.status == "online")
.order_by(SoftwareNode.last_seen_at.desc())
.with_for_update(skip_locked=True)
).scalars())
available_nodes = [item for item in nodes if item.node_id not in busy_node_ids]
available_capabilities = {
capability
for node in available_nodes
for capability in (node.capabilities or [])
}
if not available_capabilities:
return None
queued = session.execute(
select(SoftwareJob)
.where(
SoftwareJob.status == "queued",
SoftwareJob.capability.in_(available_capabilities),
)
.order_by(SoftwareJob.created_at, SoftwareJob.job_id)
).scalars()
selected: tuple[SoftwareJob, SoftwareNode] | None = None
for candidate in queued:
try:
contract = get_contract(candidate.capability)
except SoftwareContractError:
continue
node = next(
(
item for item in available_nodes
if candidate.capability in (item.capabilities or [])
and node_supports_request(
contract, candidate.request, item.runtime or {}
)
),
None,
)
if node is not None:
selected = candidate, node
break
if selected is None:
return None
candidate, node = selected
job = session.execute(
select(SoftwareJob)
.where(
SoftwareJob.job_id == candidate.job_id,
SoftwareJob.status == "queued",
)
.with_for_update(skip_locked=True)
).scalar_one_or_none()
if job is None:
return None
lease_id = uuid4()
expires_at = now + timedelta(seconds=OFFER_SECONDS)
job.node_id = node.node_id
job.lease_id = lease_id
job.lease_expires_at = expires_at
job.status = "offered"
return {
"node_id": node.node_id,
"payload": {
"job_id": str(job.job_id),
"lease_id": str(lease_id),
"lease_expires_at": expires_at.isoformat(),
"capability": job.capability,
"request_digest": job.request_digest,
"request": job.request,
"input_transfers": [
{
**item,
"download_path": (
f"/v1/software-jobs/{job.job_id}/inputs/{item['key']}"
),
}
for item in job.input_manifest
],
},
}
def get_job_input(node_id: UUID, job_id: UUID, input_key: str) -> dict | None:
"""返回任务绑定的 artifact 定位信息;调用方仍需在 user_root 内安全解析。"""
with session_scope() as session:
job = session.execute(
select(SoftwareJob).where(
SoftwareJob.job_id == job_id,
SoftwareJob.node_id == node_id,
SoftwareJob.status.in_({"offered", "dispatched", "running", "disconnected"}),
)
).scalar_one_or_none()
if job is None:
return None
manifest = next(
(item for item in job.input_manifest if item.get("key") == input_key),
None,
)
if manifest is None:
return None
artifact_id = UUID(manifest["artifact_id"])
artifact = session.execute(
select(Artifact).where(
Artifact.artifact_id == artifact_id,
Artifact.user_id == job.user_id,
Artifact.status == "active",
)
).scalar_one_or_none()
if artifact is None:
return None
return {
"capability": job.capability,
"user_id": job.user_id,
"current_path": artifact.current_path,
**manifest,
}
def get_job_output_context(node_id: UUID, job_id: UUID, lease_id: UUID, digest: str) -> dict | None:
"""返回 Node 输出上传上下文,不向 Node 暴露任何云端文件路径。"""
with session_scope() as session:
row = session.execute(
select(SoftwareJob, Task.working_dir)
.join(Task, Task.task_id == SoftwareJob.task_id)
.where(SoftwareJob.job_id == job_id)
).one_or_none()
if row is None:
return None
job, working_dir = row
if (
job.node_id != node_id
or job.lease_id != lease_id
or job.request_digest != digest
or job.status not in {"dispatched", "running", "disconnected", "succeeded"}
):
return None
return {
"capability": job.capability,
"user_id": job.user_id,
"task_id": job.task_id,
"working_dir": working_dir,
"request": job.request,
"status": job.status,
"artifact_manifest": job.artifact_manifest,
}
def validate_output_manifest(capability: str, request: dict, manifest: object) -> list[dict]:
if not isinstance(manifest, list):
raise SoftwareJobError("job artifact manifest must be a list")
try:
contract = get_contract(capability)
expected = contract.expected_outputs(request)
except SoftwareContractError as exc:
raise SoftwareJobError(str(exc)) from exc
expected_ids = set(expected)
if len(manifest) != len(expected_ids):
raise SoftwareJobError("job artifact manifest is incomplete")
normalized: list[dict] = []
seen: set[str] = set()
total = 0
for raw in manifest:
if not isinstance(raw, dict) or set(raw) != {
"artifact_id", "filename", "media_type", "size_bytes", "sha256"
}:
raise SoftwareJobError("job artifact manifest entry is invalid")
local_id = raw.get("artifact_id")
if local_id not in expected_ids or local_id in seen:
raise SoftwareJobError("job artifact manifest identity is invalid")
spec = expected[local_id]
size = raw.get("size_bytes")
digest = raw.get("sha256")
if raw.get("filename") != spec.filename or raw.get("media_type") != spec.media_type:
raise SoftwareJobError("job artifact manifest metadata does not match its identity")
if not isinstance(size, int) or isinstance(size, bool) or not 1 <= size <= MAX_OUTPUT_ARTIFACT_BYTES:
raise SoftwareJobError("job output artifact size is invalid")
if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
raise SoftwareJobError("job output artifact digest is invalid")
total += size
seen.add(local_id)
normalized.append(dict(raw))
if seen != expected_ids or total > MAX_OUTPUT_TOTAL_BYTES:
raise SoftwareJobError("job artifact manifest is incomplete or too large")
return normalized
def replay_succeeded_outputs(context: dict, manifest: list[dict]) -> list[dict] | None:
"""确认成功 Job 的 Node 重放,并返回云端已持久化的发布清单。
Node 可能在服务端完成发布后、写本地 ``upload-complete.json`` 前断线。
此时数据库中的 published manifest 是事实源;不能再按当前版本的目录规则
重做发布,否则跨版本布局调整会让已经成功的旧 Job 永久无法确认。
"""
if context.get("status") != "succeeded":
return None
published = context.get("artifact_manifest")
if not isinstance(published, list):
raise SoftwareJobError("successful job artifact manifest is invalid")
by_source = {
item.get("source_artifact_id"): item
for item in published
if isinstance(item, dict) and isinstance(item.get("source_artifact_id"), str)
}
if len(by_source) != len(published) or len(by_source) != len(manifest):
raise SoftwareJobError("successful job artifact manifest does not match replay")
identity_fields = ("filename", "media_type", "size_bytes", "sha256")
for submitted in manifest:
stored = by_source.get(submitted["artifact_id"])
if stored is None or any(stored.get(field) != submitted.get(field) for field in identity_fields):
raise SoftwareJobError("successful job artifact manifest does not match replay")
return [dict(item) for item in published]
def succeeded_output_upload_matches(
context: dict,
output_id: str,
*,
size_bytes: int,
digest: str,
) -> bool:
"""判断单个重复 PUT 是否已包含在成功 Job 的持久化清单中。"""
if context.get("status") != "succeeded":
return False
published = context.get("artifact_manifest")
if not isinstance(published, list):
raise SoftwareJobError("successful job artifact manifest is invalid")
matches = [
item
for item in published
if isinstance(item, dict) and item.get("source_artifact_id") == output_id
]
if (
len(matches) != 1
or matches[0].get("size_bytes") != size_bytes
or matches[0].get("sha256") != digest
):
raise SoftwareJobError("successful job output does not match replay")
return True
def abandon_offer(node_id: UUID, payload: dict) -> None:
"""WebSocket 发送失败时只回滚仍属于该连接租约的 offer。"""
try:
job_id = UUID(str(payload.get("job_id", "")))
lease_id = UUID(str(payload.get("lease_id", "")))
except ValueError:
return
with session_scope() as session:
job = session.execute(
select(SoftwareJob).where(SoftwareJob.job_id == job_id).with_for_update()
).scalar_one_or_none()
if (
job is not None
and job.status == "offered"
and job.node_id == node_id
and job.lease_id == lease_id
):
job.status = "queued"
job.node_id = None
job.lease_id = None
job.lease_expires_at = None
def respond_to_offer(node_id: UUID, *, accepted: bool, payload: dict) -> None:
try:
job_id = UUID(str(payload.get("job_id", "")))
lease_id = UUID(str(payload.get("lease_id", "")))
except ValueError as exc:
raise SoftwareJobError("invalid job offer response identity") from exc
now = datetime.now(timezone.utc)
with session_scope() as session:
job = session.execute(
select(SoftwareJob).where(SoftwareJob.job_id == job_id).with_for_update()
).scalar_one_or_none()
if job is None or job.node_id != node_id or job.lease_id != lease_id:
raise SoftwareJobError("job offer is stale or does not belong to this node")
if (
accepted
and job.status in {"dispatched", "running", "cancelling", "succeeded", "failed", "cancelled"}
and payload.get("request_digest") == job.request_digest
):
return
if job.status != "offered":
raise SoftwareJobError("job offer is stale or does not belong to this node")
if job.lease_expires_at is None or job.lease_expires_at <= now:
job.status = "queued"
job.node_id = None
job.lease_id = None
job.lease_expires_at = None
raise SoftwareJobError("job offer has expired")
if accepted:
if payload.get("request_digest") != job.request_digest:
raise SoftwareJobError("job request digest mismatch")
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
job.lease_id = None
job.lease_expires_at = None
job.error = {"code": "node_rejected", "detail": str(payload.get("reason") or "")[:500]}
def update_job_state(node_id: UUID, payload: dict) -> None:
job_id, lease_id, digest = _message_identity(payload)
stage = str(payload.get("stage") or "")
progress = payload.get("progress")
metrics = payload.get("metrics") or {}
if not stage or len(stage) > 100:
raise SoftwareJobError("job stage is required")
if not isinstance(progress, int) or isinstance(progress, bool) or not 0 <= progress <= 100:
raise SoftwareJobError("job progress must be between 0 and 100")
if not isinstance(metrics, dict) or len(json.dumps(metrics, ensure_ascii=False)) > 64 * 1024:
raise SoftwareJobError("job metrics are invalid")
now = datetime.now(timezone.utc)
with session_scope() as session:
job = session.execute(
select(SoftwareJob).where(SoftwareJob.job_id == job_id).with_for_update()
).scalar_one_or_none()
_assert_job_message(job, node_id, lease_id, digest)
if job.status in {"succeeded", "failed", "cancelled", "cancelling"}:
return
if not _can_accept_state(job.status):
raise SoftwareJobError("job state cannot advance from its current status")
job.status = (
"dispatched"
if stage in {"accepted", "downloading_inputs", "ready_to_run"}
else "running"
)
job.stage = stage
job.progress = progress
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
def record_job_terminal(node_id: UUID, payload: dict) -> None:
job_id, lease_id, digest = _message_identity(payload)
terminal_status = payload.get("status")
if terminal_status not in {"succeeded", "failed", "cancelled"}:
raise SoftwareJobError("invalid job terminal status")
error = payload.get("error") or {}
manifest = payload.get("artifact_manifest") or []
if not isinstance(error, dict) or len(json.dumps(error, ensure_ascii=False)) > 64 * 1024:
raise SoftwareJobError("job terminal error is invalid")
if not isinstance(manifest, list) or len(json.dumps(manifest, ensure_ascii=False)) > 256 * 1024:
raise SoftwareJobError("job artifact manifest is invalid")
now = datetime.now(timezone.utc)
with session_scope() as session:
job = session.execute(
select(SoftwareJob).where(SoftwareJob.job_id == job_id).with_for_update()
).scalar_one_or_none()
_assert_job_message(job, node_id, lease_id, digest)
if terminal_status == "succeeded":
expected = validate_output_manifest(
job.capability,
job.request,
[
{
"artifact_id": item.get("source_artifact_id"),
"filename": item.get("filename"),
"media_type": item.get("media_type"),
"size_bytes": item.get("size_bytes"),
"sha256": item.get("sha256"),
}
for item in manifest
if isinstance(item, dict)
],
)
if len(expected) != len(manifest) or any(
not _published_output_is_valid(job.capability, job.job_id, item)
for item in manifest
):
raise SoftwareJobError("successful job artifacts have not been published")
if job.status in {"succeeded", "failed", "cancelled"}:
if job.status != terminal_status:
raise SoftwareJobError("job terminal status conflicts with existing terminal")
return
if job.status not in {"offered", "dispatched", "running", "disconnected", "cancelling"}:
raise SoftwareJobError("job terminal cannot advance from its current status")
job.status = terminal_status
job.stage = "terminal"
job.progress = 100 if terminal_status == "succeeded" else job.progress
job.error = error
job.artifact_manifest = manifest
job.terminal_at = now
def _is_uuid(value: str) -> bool:
try:
UUID(value)
except ValueError:
return False
return True
def _published_output_is_valid(capability: str, job_id: UUID, item: object) -> bool:
if not isinstance(item, dict):
return False
output_id = item.get("source_artifact_id")
if not isinstance(output_id, str):
return False
try:
contract = get_contract(capability)
spec = contract.output_spec(output_id)
except SoftwareContractError:
return False
expected_path = (
f"{contract.output_namespace}/{job_id}/"
f"{software_job_output_path(capability, output_id)}"
)
if item.get("path") != expected_path:
return False
artifact_id = item.get("artifact_id")
if not spec.publish:
return artifact_id is None
return isinstance(artifact_id, str) and _is_uuid(artifact_id)
def mark_node_jobs_disconnected(node_id: UUID) -> None:
"""连接丢失后保留 Node 归属和 lease禁止任务被自动重派。"""
with session_scope() as session:
jobs = session.execute(
select(SoftwareJob)
.where(
SoftwareJob.node_id == node_id,
SoftwareJob.status.in_({"dispatched", "running"}),
)
.with_for_update()
).scalars()
for job in jobs:
job.status = "disconnected"
def _can_accept_state(status: str) -> bool:
return status in {"offered", "dispatched", "running", "disconnected"}
def _message_identity(payload: dict) -> tuple[UUID, UUID, str]:
try:
job_id = UUID(str(payload.get("job_id", "")))
lease_id = UUID(str(payload.get("lease_id", "")))
except ValueError as exc:
raise SoftwareJobError("invalid job message identity") from exc
digest = str(payload.get("request_digest") or "")
if len(digest) != 64:
raise SoftwareJobError("invalid job request digest")
return job_id, lease_id, digest
def _assert_job_message(
job: SoftwareJob | None,
node_id: UUID,
lease_id: UUID,
digest: str,
) -> None:
if (
job is None
or job.node_id != node_id
or job.lease_id != lease_id
or job.request_digest != digest
):
raise SoftwareJobError("job message does not belong to this node or lease")