zcbot/core/software_jobs.py

1291 lines
52 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,
SoftwareWorkspace,
Task,
)
OFFER_SECONDS = 60
MAX_OUTPUT_ARTIFACT_BYTES = 256 * 1024 * 1024
MAX_OUTPUT_TOTAL_BYTES = 512 * 1024 * 1024
MAX_EXPORT_ARTIFACT_BYTES = 10 * 1024 * 1024 * 1024
MAX_EXPORT_TOTAL_BYTES = 20 * 1024 * 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),
"workspace_id": (
str(row.workspace_id) if getattr(row, "workspace_id", None) else None
),
"source_job_id": (
str(row.source_job_id) if getattr(row, "source_job_id", None) else None
),
"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,
"local_manifest": getattr(row, "local_manifest", []),
"preview_manifest": getattr(row, "preview_manifest", []),
"export_status": getattr(row, "export_status", "none"),
"export_outputs": getattr(row, "export_outputs", []),
"completion_action": getattr(row, "completion_action", "report"),
"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}"
),
"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
job.followup_status = "pending"
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,
completion_action: str = "report",
workspace_id: UUID | None = None,
source_job_id: UUID | None = None,
) -> tuple[dict, bool]:
key = idempotency_key.strip()
if not key or len(key) > 200:
raise SoftwareJobError("idempotency_key must contain 1 to 200 characters")
if completion_action not in {"report", "analyze"}:
raise SoftwareJobError("completion_action must be report or analyze")
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")
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
or getattr(existing, "completion_action", "report") != completion_action
or workspace_id is not None and existing.workspace_id != workspace_id
or source_job_id is not None and existing.source_job_id != source_job_id
):
raise SoftwareJobError(
"idempotency key was already used for a different request"
)
return _job_dict(existing), False
if (workspace_id is None) != (source_job_id is None):
raise SoftwareJobError(
"workspace_id and source_job_id must be provided together"
)
workspace: SoftwareWorkspace | None = None
source: SoftwareJob | None = None
if workspace_id is not None:
workspace = session.execute(
select(SoftwareWorkspace).where(
SoftwareWorkspace.workspace_id == workspace_id,
SoftwareWorkspace.user_id == user_id,
).with_for_update()
).scalar_one_or_none()
if workspace is None:
raise SoftwareJobError("software workspace not found")
if workspace.capability != capability:
raise SoftwareJobError("software workspace capability does not match")
if workspace.status in {"deleting", "deleted", "lost"}:
raise SoftwareJobError("software workspace is not available")
source = session.execute(
select(SoftwareJob).where(
SoftwareJob.job_id == source_job_id,
SoftwareJob.workspace_id == workspace_id,
SoftwareJob.user_id == user_id,
)
).scalar_one_or_none()
if source is None or source.status != "succeeded":
raise SoftwareJobError("source software job is not a successful workspace revision")
if workspace.head_job_id != source.job_id:
raise SoftwareJobError("source software job is not the workspace head")
if source.export_status == "pending":
raise SoftwareJobError(
"software workspace must finish its pending export before revision"
)
active = session.execute(
select(SoftwareJob.job_id).where(
SoftwareJob.workspace_id == workspace_id,
SoftwareJob.status.in_({
"queued", "offered", "dispatched", "running",
"disconnected", "cancelling",
}),
).limit(1)
).first()
if active is not None:
raise SoftwareJobError("software workspace already has an active job")
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
or getattr(existing, "completion_action", "report") != completion_action
or workspace_id is not None and existing.workspace_id != workspace_id
or source_job_id is not None and existing.source_job_id != source_job_id
):
raise SoftwareJobError("idempotency key was already used for a different request")
return _job_dict(existing), False
effective_workspace_id = (
workspace_id
or (uuid4() if contract.workspace is not None else None)
)
new_workspace: SoftwareWorkspace | None = None
if workspace is None and effective_workspace_id is not None:
new_workspace = SoftwareWorkspace(
workspace_id=effective_workspace_id,
user_id=user_id,
capability=capability,
status="pending",
size_bytes=0,
state_manifest=[],
retention_until=datetime.now(timezone.utc) + timedelta(days=30),
)
row = SoftwareJob(
job_id=uuid4(),
user_id=user_id,
task_id=task_id,
workspace_id=effective_workspace_id,
source_job_id=source_job_id,
idempotency_key=key,
capability=capability,
request=normalized,
request_digest=digest,
input_manifest=input_manifest,
status="queued",
stage="",
metrics={},
error={},
artifact_manifest=[],
local_manifest=[],
preview_manifest=[],
export_status="none",
export_outputs=[],
completion_action=completion_action,
followup_status="none",
)
try:
with session.begin_nested():
if new_workspace is not None:
session.add(new_workspace)
# SoftwareJob.workspace_id and SoftwareWorkspace.head_job_id form a
# nullable FK cycle. No ORM relationship exists to give the unit of
# work an insertion dependency, so flush the parent explicitly before
# inserting the first job in a workspace.
session.flush([new_workspace])
session.add(row)
session.flush([row])
return _job_dict(row), True
except IntegrityError as exc:
existing = session.execute(
select(SoftwareJob).where(
SoftwareJob.user_id == user_id,
SoftwareJob.idempotency_key == key,
)
).scalar_one_or_none()
if existing is None:
diagnostic = getattr(getattr(exc, "orig", None), "diag", None)
constraint = getattr(diagnostic, "constraint_name", None)
suffix = f" ({constraint})" if constraint else ""
raise SoftwareJobError(
f"software job database integrity validation failed{suffix}"
) from exc
if (
existing.task_id != task_id
or existing.capability != capability
or existing.request_digest != digest
or getattr(existing, "completion_action", "report") != completion_action
or workspace_id is not None and existing.workspace_id != workspace_id
or source_job_id is not None and existing.source_job_id != source_job_id
):
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_working_dir(user_id: UUID, job_id: UUID) -> str | None:
"""返回当前用户指定 Job 所属 task 的工作目录。"""
with session_scope() as session:
return session.execute(
select(Task.working_dir)
.join(SoftwareJob, SoftwareJob.task_id == Task.task_id)
.where(SoftwareJob.job_id == job_id, SoftwareJob.user_id == user_id)
).scalar_one_or_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")
if source.status != "succeeded" or source.workspace_id is None:
raise SoftwareJobError("source software job has no available workspace state")
capability = source.capability
if get_contract(capability).workspace is None:
raise SoftwareJobError("capability does not support workspace continuation")
workspace_id = source.workspace_id
completion_action = getattr(source, "completion_action", "report")
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,
completion_action=completion_action,
workspace_id=workspace_id,
source_job_id=source_job_id,
request={
"schema_version": schema_version,
"inputs": inputs,
"operation": operation,
"outputs": outputs,
},
)
def request_job_analysis(user_id: UUID, job_id: UUID) -> dict:
"""为已成功任务排入一次分析续跑pending/running 请求保持幂等。"""
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 != "succeeded":
raise SoftwareJobError("only a succeeded job can be analyzed")
job.completion_action = "analyze"
if job.followup_status not in {"pending", "running"}:
job.followup_status = "pending"
return _job_dict(job)
def request_job_export(user_id: UUID, job_id: UUID, output_ids: list[str]) -> dict:
"""请求原 Node 将当前 Workspace head 的指定本地输出发布为正式 Artifact。"""
requested = list(dict.fromkeys(output_ids))
if not requested or len(requested) > 16 or any(not isinstance(item, str) for item in requested):
raise SoftwareJobError("export output_ids must contain 1 to 16 output identities")
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")
contract = get_contract(job.capability)
if job.status != "succeeded" or contract.workspace is None or job.workspace_id is None:
raise SoftwareJobError("job has no exportable workspace state")
workspace = session.execute(
select(SoftwareWorkspace).where(
SoftwareWorkspace.workspace_id == job.workspace_id
).with_for_update()
).scalar_one_or_none()
if workspace is None or workspace.head_job_id != job.job_id:
raise SoftwareJobError("only the current workspace head can be exported")
active = session.execute(
select(SoftwareJob.job_id).where(
SoftwareJob.workspace_id == job.workspace_id,
SoftwareJob.job_id != job.job_id,
SoftwareJob.status.in_({
"queued", "offered", "dispatched", "running",
"disconnected", "cancelling",
}),
).limit(1)
).first()
if active is not None:
raise SoftwareJobError("software workspace has an active revision")
local_by_id = {
item.get("artifact_id"): item
for item in job.local_manifest
if isinstance(item, dict) and isinstance(item.get("artifact_id"), str)
}
already_exported = {
item.get("source_artifact_id")
for item in job.artifact_manifest
if isinstance(item, dict) and item.get("artifact_id")
}
for output_id in requested:
if output_id not in local_by_id:
raise SoftwareJobError(f"local output is unavailable: {output_id}")
if not contract.output_spec(output_id).publish:
raise SoftwareJobError(f"output is internal and cannot be exported: {output_id}")
pending = [item for item in requested if item not in already_exported]
if not pending:
job.export_status = "completed"
return _job_dict(job)
if job.export_status == "pending":
if pending == list(job.export_outputs):
return _job_dict(job)
raise SoftwareJobError("software job already has a pending export")
job.export_outputs = pending
job.export_status = "pending"
return _job_dict(job)
def pending_node_exports(node_id: UUID) -> list[dict]:
with session_scope() as session:
jobs = session.execute(
select(SoftwareJob).where(
SoftwareJob.node_id == node_id,
SoftwareJob.status == "succeeded",
SoftwareJob.export_status == "pending",
).order_by(SoftwareJob.updated_at, SoftwareJob.job_id)
).scalars()
return [
{
"job_id": str(job.job_id),
"lease_id": str(job.lease_id),
"request_digest": job.request_digest,
"output_ids": list(job.export_outputs),
}
for job in jobs
if job.lease_id is not None
]
def record_job_export(
node_id: UUID, job_id: UUID, output_ids: list[str], published: list[dict]
) -> dict:
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.status != "succeeded"
or job.export_status not in {"pending", "completed"}
):
raise SoftwareJobError("software export request is stale")
requested = list(job.export_outputs)
if requested != output_ids:
raise SoftwareJobError("software export outputs do not match the request")
by_source = {
item.get("source_artifact_id"): item
for item in job.artifact_manifest
if isinstance(item, dict)
}
for item in published:
by_source[item["source_artifact_id"]] = item
job.artifact_manifest = list(by_source.values())
job.export_status = "completed"
job.completion_action = "report"
job.followup_status = "pending"
return _job_dict(job)
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
workspace = (
session.get(SoftwareWorkspace, candidate.workspace_id)
if getattr(candidate, "workspace_id", None) is not None
else None
)
if getattr(candidate, "workspace_id", None) is not None and (
workspace is None
or workspace.status in {"deleting", "deleted", "lost"}
or (workspace.head_job_id is not None and workspace.home_node_id is None)
):
continue
home_node_id = workspace.home_node_id if workspace is not None else None
node = next(
(
item for item in available_nodes
if candidate.capability in (item.capabilities or [])
and (home_node_id is None or item.node_id == home_node_id)
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"
workspace = (
session.get(SoftwareWorkspace, job.workspace_id)
if getattr(job, "workspace_id", None) is not None
else None
)
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,
"workspace": (
{
"workspace_id": str(job.workspace_id),
"source_job_id": (
str(job.source_job_id) if job.source_job_id else None
),
"mode": "continue" if job.source_job_id else "new",
}
if workspace is not None
else None
),
"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,
"local_manifest": getattr(job, "local_manifest", []),
"preview_manifest": getattr(job, "preview_manifest", []),
"workspace_id": getattr(job, "workspace_id", None),
"export_status": getattr(job, "export_status", "none"),
"export_outputs": getattr(job, "export_outputs", []),
}
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 = {}
if getattr(job, "workspace_id", None) is not None:
workspace = session.execute(
select(SoftwareWorkspace).where(
SoftwareWorkspace.workspace_id == job.workspace_id
).with_for_update()
).scalar_one_or_none()
if workspace is None:
raise SoftwareJobError("software workspace no longer exists")
if workspace.home_node_id not in {None, node_id}:
raise SoftwareJobError("software workspace belongs to another node")
workspace.home_node_id = node_id
if workspace.status == "pending":
workspace.status = "assigned"
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 []
local_manifest = payload.get("local_manifest") or manifest
preview_manifest = payload.get("preview_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")
if (
not isinstance(local_manifest, list)
or len(json.dumps(local_manifest, ensure_ascii=False)) > 256 * 1024
or not isinstance(preview_manifest, list)
or len(json.dumps(preview_manifest, ensure_ascii=False)) > 64 * 1024
):
raise SoftwareJobError("job local or preview 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") or item.get("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 local_manifest
if isinstance(item, dict)
],
)
contract = get_contract(job.capability)
if len(expected) != len(local_manifest):
raise SoftwareJobError("successful job local outputs are incomplete")
if contract.workspace is None and 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.local_manifest = local_manifest
job.preview_manifest = preview_manifest
job.terminal_at = now
job.followup_status = "pending"
if terminal_status == "succeeded":
if getattr(job, "workspace_id", None) is not None:
workspace = session.execute(
select(SoftwareWorkspace).where(
SoftwareWorkspace.workspace_id == job.workspace_id
).with_for_update()
).scalar_one_or_none()
if workspace is None or workspace.home_node_id != node_id:
raise SoftwareJobError("software workspace ownership is inconsistent")
if job.source_job_id is not None and workspace.head_job_id != job.source_job_id:
raise SoftwareJobError("software workspace head changed during execution")
workspace.head_job_id = job.job_id
workspace.status = "active"
workspace.last_used_at = now
workspace.state_manifest = local_manifest
workspace.size_bytes = int(payload.get("workspace_size_bytes") or 0)
workspace.last_reported_at = now
workspace.retention_until = now + timedelta(days=30)
elif (
get_contract(job.capability).workspace is not None
and getattr(job, "workspace_id", None) is not None
):
workspace = session.execute(
select(SoftwareWorkspace).where(
SoftwareWorkspace.workspace_id == job.workspace_id
).with_for_update()
).scalar_one_or_none()
if workspace is not None and workspace.head_job_id is None:
workspace.status = "failed"
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")