"""专业软件任务的校验、幂等持久化和 offer 状态机。""" from __future__ import annotations import json import re 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 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, "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 offer_next_job(node_ids: set[UUID]) -> dict | None: """选择最早可执行的 Job–Node 组合,避免跨 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 = {} 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 job.metrics = metrics 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")