refactor(software): rename Windows execution domain

This commit is contained in:
caoqianming 2026-08-13 15:28:04 +08:00
parent 3e0a719f80
commit ded8cba54c
25 changed files with 399 additions and 340 deletions

View File

@ -460,11 +460,11 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
第一阶段以 `docs/windows-node-mvp-intranet.md` 为实现契约Windows Node 只作为受控执行节点,通过出站 HTTP/WS 主动连接 zcbot首批能力固定为 `origin.plot@v1`。长期方案中的 mTLS、Service/DesktopRunner 双进程、完整租约与多节点调度暂不进入 MVP但 URL path、Node ID、Bearer Header 和任务协议保留原位升级空间。 第一阶段以 `docs/windows-node-mvp-intranet.md` 为实现契约Windows Node 只作为受控执行节点,通过出站 HTTP/WS 主动连接 zcbot首批能力固定为 `origin.plot@v1`。长期方案中的 mTLS、Service/DesktopRunner 双进程、完整租约与多节点调度暂不进入 MVP但 URL path、Node ID、Bearer Header 和任务协议保留原位升级空间。
云端控制面使用独立的 `compute_node_enrollments` 与 `compute_nodes`,不复用用户外部系统连接。管理员创建的一次性注册码具有 128 bit 随机熵,数据库只保存 SHA-256 摘要;节点注册在行锁事务中校验有效期、预期名称和允许能力,成功后原子消费。每个节点获得独立高熵 Token数据库只保存 bcrypt 强哈希,明文仅在注册响应出现一次。 云端控制面使用独立的 `software_node_enrollments` 与 `software_nodes`,不复用用户外部系统连接。管理员创建的一次性注册码具有 128 bit 随机熵,数据库只保存 SHA-256 摘要;节点注册在行锁事务中校验有效期、预期名称和允许能力,成功后原子消费。每个节点获得独立高熵 Token数据库只保存 bcrypt 强哈希,明文仅在注册响应出现一次。
Node 通过 `Authorization: Bearer``X-Node-Id` 建立 `/v1/compute/nodes/connect` WebSocket。进程内 Connection Manager 保证同一节点单活,新连接关闭旧连接;`hello`/`heartbeat` 更新版本、容量、软件健康与最后在线时间。管理员禁用节点时先持久化禁用态,再关闭现有连接;断线收尾不得覆盖禁用态。当前单活只覆盖单 Web 进程,生产启用多实例前必须增加 Redis/PG fencing 或将 Node API 固定路由到单一控制面实例。 Node 通过 `Authorization: Bearer``X-Node-Id` 建立 `/v1/software-nodes/connect` WebSocket。进程内 Connection Manager 保证同一节点单活,新连接关闭旧连接;`hello`/`heartbeat` 更新版本、容量、软件健康与最后在线时间。管理员禁用节点时先持久化禁用态,再关闭现有连接;断线收尾不得覆盖禁用态。当前单活只覆盖单 Web 进程,生产启用多实例前必须增加 Redis/PG fencing 或将 Node API 固定路由到单一控制面实例。
第二阶段已增加 `compute_jobs` 账本与 `origin.plot@v1` 的 offer/accept 骨架。用户只能在本人 task 下以幂等键提交固定 schema云端规范化请求并记录 SHA-256按当前进程真实在线、能力匹配、健康且有空闲 slot 的 Node 创建短期 offer。Node 再次校验 schema、图形类型和输出格式使用 write-through、flush 与原子 rename 先落本机任务目录,再回 `job_accept`;重复 job 只有 digest 一致才接受。过期或发送失败的 offer 回到队列lease、Node 和 digest 不匹配的响应被拒绝。Node 接收后云端进入 `dispatched` 而非 `running`,并将 slot 降为 0只有固定 Worker 真正启动后才进入 `origin_running` 第二阶段已增加 `software_jobs`(专业软件任务)账本与 `origin.plot@v1` 的 offer/accept 骨架。用户只能在本人 task 下以幂等键提交固定 schema云端规范化请求并记录 SHA-256按当前进程真实在线、能力匹配、健康且有空闲 slot 的 Node 创建短期 offer。Node 再次校验 schema、图形类型和输出格式使用 write-through、flush 与原子 rename 先落本机任务目录,再回 `job_accept`;重复 job 只有 digest 一致才接受。过期或发送失败的 offer 回到队列lease、Node 和 digest 不匹配的响应被拒绝。Node 接收后云端进入 `dispatched` 而非 `running`,并将 slot 降为 0只有固定 Worker 真正启动后才进入 `origin_running`
第三阶段补齐输入下载与恢复状态协议:`input_id` 固定为用户已有 artifact UUID提交时快照文件名、大小和 SHA-256只允许 CSV/XLSX/JSON 且不超过 100 MiB。Node 以自身 Bearer 身份访问任务绑定的只读下载端点,流式写入本 job 的 `input/`,同时限制声明大小并校验 SHA-256完成后原子 rename不暴露工作区路径。Node 会原子读取/补报 `terminal.json`,断线后云端把活动任务标记 `disconnected` 并保留 Node/lease重连按 job、lease、digest 恢复下载或幂等补报终态,不自动重派。 第三阶段补齐输入下载与恢复状态协议:`input_id` 固定为用户已有 artifact UUID提交时快照文件名、大小和 SHA-256只允许 CSV/XLSX/JSON 且不超过 100 MiB。Node 以自身 Bearer 身份访问任务绑定的只读下载端点,流式写入本 job 的 `input/`,同时限制声明大小并校验 SHA-256完成后原子 rename不暴露工作区路径。Node 会原子读取/补报 `terminal.json`,断线后云端把活动任务标记 `disconnected` 并保留 Node/lease重连按 job、lease、digest 恢复下载或幂等补报终态,不自动重派。

View File

@ -341,7 +341,7 @@ core/llm_transport.py 438 ← wire 层健壮性:畸形/吐空检测+留
core/tool_registry.py 264 ← 声明式工具注册表((组名,gate,factory);secret/host 工具按实际能力 gate) core/tool_registry.py 264 ← 声明式工具注册表((组名,gate,factory);secret/host 工具按实际能力 gate)
core/context.py 95 ← LLM 调用前压缩旧 tool / load_skill 消息(带压力门槛),保 tool_call 协议字段 core/context.py 95 ← LLM 调用前压缩旧 tool / load_skill 消息(带压力门槛),保 tool_call 协议字段
core/external_systems/*.py ← 外部系统目录/用户授权/凭据加密 + 通用 OpenAPI/MCP connector core/external_systems/*.py ← 外部系统目录/用户授权/凭据加密 + 通用 OpenAPI/MCP connector
core/compute_nodes.py ← Windows Node 注册码、身份认证与运行状态 core/software_nodes.py ← Windows Node 注册码、身份认证与运行状态
core/sinks.py 101 core/sinks.py 101
core/paths.py 50 ← task_dir db form 归一 core/paths.py 50 ← task_dir db form 归一
core/probe.py 243 core/probe.py 243
@ -363,7 +363,7 @@ tools/{base,output,fs,shell,run_python,skill_tool,skill_authoring,media_common,s
main.py ~210 ← 入口:web / db / probe / user / sandbox check main.py ~210 ← 入口:web / db / probe / user / sandbox check
db/migrations/versions/ 0001-0030 db/migrations/versions/ 0001-0030
web/app.py ~210 ← 工厂 + lifespan 编排(07-23 拆分;路由在 routers/,协程在 background 等) web/app.py ~210 ← 工厂 + lifespan 编排(07-23 拆分;路由在 routers/,协程在 background 等)
web/routers/*.py ← 含 external_systems 用户连接与 compute_nodes 节点路由 web/routers/*.py ← 含 external_systems 用户连接与 software_nodes 节点路由
web/{background,scheduler_runner,wechat_runner}.py ← lifespan 后台协程按域析出 web/{background,scheduler_runner,wechat_runner}.py ← lifespan 后台协程按域析出
web/{runs,common,schemas,model_gate,userfiles}.py ← BG worker/共享 helper/请求体/档位门控/路径安全 web/{runs,common,schemas,model_gate,userfiles}.py ← BG worker/共享 helper/请求体/档位门控/路径安全
web/auth.py ~190 ← 邮箱密码 + platform_key → JWT web/auth.py ~190 ← 邮箱密码 + platform_key → JWT

12
RUN.md
View File

@ -1054,14 +1054,14 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
### Windows Node 内网 MVP开发中 ### Windows Node 内网 MVP开发中
先执行 `alembic upgrade head` 创建 `compute_node_enrollments` 和 `compute_nodes`。不要在未确认目标数据库时运行迁移;本机 `.env``ZCBOT_DB_URL` 可能是生产隧道。 先执行 `alembic upgrade head` 创建 `software_node_enrollments` 和 `software_nodes`。不要在未确认目标数据库时运行迁移;本机 `.env``ZCBOT_DB_URL` 可能是生产隧道。
云端当前提供: 云端当前提供:
- 管理员 `POST /v1/admin/compute-node-enrollments` 创建一次性注册码; - 管理员 `POST /v1/admin/software-node-enrollments` 创建一次性注册码;
- Node `POST /v1/compute/nodes/enroll` 注册并一次性取得 `node_id`、`node_token` - Node `POST /v1/software-nodes/enroll` 注册并一次性取得 `node_id`、`node_token`
- Node 携带 `Authorization: Bearer <node_token>``X-Node-Id` 连接 `WS /v1/compute/nodes/connect` - Node 携带 `Authorization: Bearer <node_token>``X-Node-Id` 连接 `WS /v1/software-nodes/connect`
- 管理员 `GET /v1/admin/compute-nodes` 查看节点,`PATCH /v1/admin/compute-nodes/{node_id}` 启停节点,`DELETE /v1/admin/compute-nodes/{node_id}` 永久删除节点身份。 - 管理员 `GET /v1/admin/software-nodes` 查看节点,`PATCH /v1/admin/software-nodes/{node_id}` 启停节点,`DELETE /v1/admin/software-nodes/{node_id}` 永久删除节点身份。
Node API 只能绑定受控内网地址并由安全组限制来源 IP。当前 HTTP/WS 链路不加密;跨安全域、公网或不可信终端接入前,必须先升级 HTTPS/WSS。多 Web 实例部署时Node API 暂时固定路由到单一实例,直至 Connection Manager 增加跨实例 fencing。 Node API 只能绑定受控内网地址并由安全组限制来源 IP。当前 HTTP/WS 链路不加密;跨安全域、公网或不可信终端接入前,必须先升级 HTTPS/WSS。多 Web 实例部署时Node API 暂时固定路由到单一实例,直至 Connection Manager 增加跨实例 fencing。
@ -1088,7 +1088,7 @@ windows-node\install-origin-runtime.ps1 -BootstrapPython D:\programs\Python312\p
直接双击 EXE 启动托盘 UI红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开配置窗。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless` 直接双击 EXE 启动托盘 UI红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开配置窗。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless`
经 nginx 反代时,`/v1/compute/nodes/connect` 必须单独透传 WebSocket Upgrade/Connection 头并设置长连接超时,配置见 `deploy/nginx/zcbot.conf.example`。若注册成功后节点持续显示“连接中断,等待重连”,先用 WebSocket 握手检查该路径;返回普通 HTTP 404 通常表示请求落入了清空 `Connection` 头的默认 location。 经 nginx 反代时,`/v1/software-nodes/connect` 必须单独透传 WebSocket Upgrade/Connection 头并设置长连接超时,配置见 `deploy/nginx/zcbot.conf.example`。若注册成功后节点持续显示“连接中断,等待重连”,先用 WebSocket 握手检查该路径;返回普通 HTTP 404 通常表示请求落入了清空 `Connection` 头的默认 location。
- **入口**:`main.py`(`web / db / probe / user`)→ `core/agent_builder.py::build_agent` - **入口**:`main.py`(`web / db / probe / user`)→ `core/agent_builder.py::build_agent`

View File

@ -1,4 +1,4 @@
"""Origin 受控计算任务的校验、幂等持久化和 offer 状态机。""" """专业软件任务的校验、幂等持久化和 offer 状态机。"""
from __future__ import annotations from __future__ import annotations
@ -11,9 +11,9 @@ from uuid import UUID, uuid4
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from core.compute_nodes import ComputeNodeError, SUPPORTED_CAPABILITIES from core.software_nodes import SUPPORTED_CAPABILITIES
from core.storage.engine import session_scope from core.storage.engine import session_scope
from core.storage.models import Artifact, ComputeJob, ComputeNode, Task from core.storage.models import Artifact, SoftwareJob, SoftwareNode, Task
OFFER_SECONDS = 60 OFFER_SECONDS = 60
ALLOWED_PLOT_TYPES = frozenset( ALLOWED_PLOT_TYPES = frozenset(
@ -23,6 +23,10 @@ ALLOWED_OUTPUT_FORMATS = frozenset({"opju", "png", "svg", "pdf"})
ALLOWED_INPUT_SUFFIXES = frozenset({".csv", ".xlsx", ".json"}) ALLOWED_INPUT_SUFFIXES = frozenset({".csv", ".xlsx", ".json"})
MAX_INPUT_BYTES = 100 * 1024 * 1024 MAX_INPUT_BYTES = 100 * 1024 * 1024
MAX_OUTPUT_ARTIFACT_BYTES = 256 * 1024 * 1024 MAX_OUTPUT_ARTIFACT_BYTES = 256 * 1024 * 1024
class SoftwareJobError(Exception):
pass
MAX_OUTPUT_TOTAL_BYTES = 512 * 1024 * 1024 MAX_OUTPUT_TOTAL_BYTES = 512 * 1024 * 1024
OUTPUT_ARTIFACTS = { OUTPUT_ARTIFACTS = {
"project": ("project.opju", "application/x-origin-project", "opju"), "project": ("project.opju", "application/x-origin-project", "opju"),
@ -40,41 +44,41 @@ def _has_only(value: dict, fields: set[str]) -> bool:
def _canonical_request(request: dict) -> tuple[dict, str]: def _canonical_request(request: dict) -> tuple[dict, str]:
if not isinstance(request, dict) or set(request) != {"schema_version", "input", "plot", "output"}: if not isinstance(request, dict) or set(request) != {"schema_version", "input", "plot", "output"}:
raise ComputeNodeError("invalid origin plot request fields") raise SoftwareJobError("invalid origin plot request fields")
if request.get("schema_version") != 1: if request.get("schema_version") != 1:
raise ComputeNodeError("unsupported origin plot schema version") raise SoftwareJobError("unsupported origin plot schema version")
input_spec = request.get("input") input_spec = request.get("input")
plot = request.get("plot") plot = request.get("plot")
output = request.get("output") output = request.get("output")
if not all(isinstance(item, dict) for item in (input_spec, plot, output)): if not all(isinstance(item, dict) for item in (input_spec, plot, output)):
raise ComputeNodeError("origin plot request sections must be objects") raise SoftwareJobError("origin plot request sections must be objects")
if not _has_only(input_spec, {"input_id", "sheet"}): if not _has_only(input_spec, {"input_id", "sheet"}):
raise ComputeNodeError("unsupported origin input fields") raise SoftwareJobError("unsupported origin input fields")
try: try:
UUID(str(input_spec.get("input_id") or "")) UUID(str(input_spec.get("input_id") or ""))
except ValueError as exc: except ValueError as exc:
raise ComputeNodeError("input.input_id must be an artifact UUID") from exc raise SoftwareJobError("input.input_id must be an artifact UUID") from exc
if "sheet" in input_spec and ( if "sheet" in input_spec and (
not isinstance(input_spec["sheet"], str) or not 1 <= len(input_spec["sheet"]) <= 128 not isinstance(input_spec["sheet"], str) or not 1 <= len(input_spec["sheet"]) <= 128
): ):
raise ComputeNodeError("input.sheet must be a string") raise SoftwareJobError("input.sheet must be a string")
if not _has_only( if not _has_only(
plot, plot,
{"type", "x", "y", "template", "title", "x_axis", "y_axis", "legend", "error_bars"}, {"type", "x", "y", "template", "title", "x_axis", "y_axis", "legend", "error_bars"},
): ):
raise ComputeNodeError("unsupported origin plot fields") raise SoftwareJobError("unsupported origin plot fields")
if plot.get("type") not in ALLOWED_PLOT_TYPES: if plot.get("type") not in ALLOWED_PLOT_TYPES:
raise ComputeNodeError("unsupported origin plot type") raise SoftwareJobError("unsupported origin plot type")
if "title" in plot and ( if "title" in plot and (
not isinstance(plot["title"], str) or len(plot["title"]) > 500 not isinstance(plot["title"], str) or len(plot["title"]) > 500
): ):
raise ComputeNodeError("plot.title must be a string") raise SoftwareJobError("plot.title must be a string")
if plot.get("template", "publication_double_column") != "publication_double_column": if plot.get("template", "publication_double_column") != "publication_double_column":
raise ComputeNodeError("unsupported origin plot template") raise SoftwareJobError("unsupported origin plot template")
x_column = plot.get("x") x_column = plot.get("x")
y_columns = plot.get("y") y_columns = plot.get("y")
if not isinstance(x_column, str) or not 1 <= len(x_column) <= 128: if not isinstance(x_column, str) or not 1 <= len(x_column) <= 128:
raise ComputeNodeError("plot.x must be a column name") raise SoftwareJobError("plot.x must be a column name")
if isinstance(y_columns, str): if isinstance(y_columns, str):
y_columns = [y_columns] y_columns = [y_columns]
if ( if (
@ -83,7 +87,7 @@ def _canonical_request(request: dict) -> tuple[dict, str]:
or len(y_columns) != len(set(y_columns)) or len(y_columns) != len(set(y_columns))
or any(not isinstance(item, str) or not 1 <= len(item) <= 128 for item in y_columns) or any(not isinstance(item, str) or not 1 <= len(item) <= 128 for item in y_columns)
): ):
raise ComputeNodeError("plot.y must contain 1 to 16 unique column names") raise SoftwareJobError("plot.y must contain 1 to 16 unique column names")
for axis_name in ("x_axis", "y_axis"): for axis_name in ("x_axis", "y_axis"):
axis = plot.get(axis_name) axis = plot.get(axis_name)
if axis is not None and ( if axis is not None and (
@ -95,7 +99,7 @@ def _canonical_request(request: dict) -> tuple[dict, str]:
for name in ("title", "unit") for name in ("title", "unit")
) )
): ):
raise ComputeNodeError(f"invalid {axis_name}") raise SoftwareJobError(f"invalid {axis_name}")
legend = plot.get("legend") legend = plot.get("legend")
if legend is not None and ( if legend is not None and (
not isinstance(legend, dict) not isinstance(legend, dict)
@ -104,21 +108,21 @@ def _canonical_request(request: dict) -> tuple[dict, str]:
or legend.get("enabled", True) is not True or legend.get("enabled", True) is not True
or legend.get("position", "top_right") != "top_right" or legend.get("position", "top_right") != "top_right"
): ):
raise ComputeNodeError("invalid plot.legend") raise SoftwareJobError("invalid plot.legend")
if plot.get("error_bars") is not None: if plot.get("error_bars") is not None:
raise ComputeNodeError("error bars are not supported in origin.plot@v1") raise SoftwareJobError("error bars are not supported in origin.plot@v1")
if not _has_only(output, {"formats", "dpi", "capture_screenshots", "record_video"}): if not _has_only(output, {"formats", "dpi", "capture_screenshots", "record_video"}):
raise ComputeNodeError("unsupported origin output fields") raise SoftwareJobError("unsupported origin output fields")
if any( if any(
name in output and not isinstance(output[name], bool) name in output and not isinstance(output[name], bool)
for name in ("capture_screenshots", "record_video") for name in ("capture_screenshots", "record_video")
): ):
raise ComputeNodeError("origin output capture flags must be boolean") raise SoftwareJobError("origin output capture flags must be boolean")
if output.get("record_video", False): if output.get("record_video", False):
raise ComputeNodeError("origin video recording is not supported") raise SoftwareJobError("origin video recording is not supported")
dpi = output.get("dpi", 300) dpi = output.get("dpi", 300)
if not isinstance(dpi, int) or isinstance(dpi, bool) or not 72 <= dpi <= 1200: if not isinstance(dpi, int) or isinstance(dpi, bool) or not 72 <= dpi <= 1200:
raise ComputeNodeError("output.dpi must be between 72 and 1200") raise SoftwareJobError("output.dpi must be between 72 and 1200")
formats = output.get("formats") formats = output.get("formats")
if ( if (
not isinstance(formats, list) not isinstance(formats, list)
@ -126,15 +130,15 @@ def _canonical_request(request: dict) -> tuple[dict, str]:
or len(formats) != len(set(formats)) or len(formats) != len(set(formats))
or any(item not in ALLOWED_OUTPUT_FORMATS for item in formats) or any(item not in ALLOWED_OUTPUT_FORMATS for item in formats)
): ):
raise ComputeNodeError("output.formats contains unsupported values") raise SoftwareJobError("output.formats contains unsupported values")
encoded = json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":")) encoded = json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
if len(encoded.encode("utf-8")) > 256 * 1024: if len(encoded.encode("utf-8")) > 256 * 1024:
raise ComputeNodeError("origin plot request is too large") raise SoftwareJobError("origin plot request is too large")
normalized = json.loads(encoded) normalized = json.loads(encoded)
return normalized, sha256(encoded.encode("utf-8")).hexdigest() return normalized, sha256(encoded.encode("utf-8")).hexdigest()
def _job_dict(row: ComputeJob) -> dict: def _job_dict(row: SoftwareJob) -> dict:
return { return {
"job_id": str(row.job_id), "job_id": str(row.job_id),
"task_id": str(row.task_id), "task_id": str(row.task_id),
@ -163,16 +167,16 @@ def create_job(
) -> tuple[dict, bool]: ) -> tuple[dict, bool]:
key = idempotency_key.strip() key = idempotency_key.strip()
if not key or len(key) > 200: if not key or len(key) > 200:
raise ComputeNodeError("idempotency_key must contain 1 to 200 characters") raise SoftwareJobError("idempotency_key must contain 1 to 200 characters")
if capability not in SUPPORTED_CAPABILITIES: if capability not in SUPPORTED_CAPABILITIES:
raise ComputeNodeError("unsupported capability") raise SoftwareJobError("unsupported capability")
normalized, digest = _canonical_request(request) normalized, digest = _canonical_request(request)
with session_scope() as session: with session_scope() as session:
task = session.execute( task = session.execute(
select(Task.task_id).where(Task.task_id == task_id, Task.user_id == user_id) select(Task.task_id).where(Task.task_id == task_id, Task.user_id == user_id)
).first() ).first()
if task is None: if task is None:
raise ComputeNodeError("task not found") raise SoftwareJobError("task not found")
artifact_id = UUID(normalized["input"]["input_id"]) artifact_id = UUID(normalized["input"]["input_id"])
artifact = session.execute( artifact = session.execute(
select(Artifact).where( select(Artifact).where(
@ -182,10 +186,10 @@ def create_job(
) )
).scalar_one_or_none() ).scalar_one_or_none()
if artifact is None: if artifact is None:
raise ComputeNodeError("input artifact not found") raise SoftwareJobError("input artifact not found")
suffix = "." + artifact.current_path.rsplit(".", 1)[-1].lower() if "." in artifact.current_path else "" suffix = "." + artifact.current_path.rsplit(".", 1)[-1].lower() if "." in artifact.current_path else ""
if suffix not in ALLOWED_INPUT_SUFFIXES: if suffix not in ALLOWED_INPUT_SUFFIXES:
raise ComputeNodeError("input artifact type is not supported") raise SoftwareJobError("input artifact type is not supported")
if ( if (
artifact.size_bytes is None artifact.size_bytes is None
or artifact.size_bytes < 0 or artifact.size_bytes < 0
@ -193,7 +197,7 @@ def create_job(
or not artifact.content_sha256 or not artifact.content_sha256
or len(artifact.content_sha256) != 64 or len(artifact.content_sha256) != 64
): ):
raise ComputeNodeError("input artifact metadata is incomplete or too large") raise SoftwareJobError("input artifact metadata is incomplete or too large")
input_manifest = { input_manifest = {
"artifact_id": str(artifact.artifact_id), "artifact_id": str(artifact.artifact_id),
"filename": artifact.current_path.replace("\\", "/").rsplit("/", 1)[-1], "filename": artifact.current_path.replace("\\", "/").rsplit("/", 1)[-1],
@ -201,9 +205,9 @@ def create_job(
"sha256": artifact.content_sha256, "sha256": artifact.content_sha256,
} }
existing = session.execute( existing = session.execute(
select(ComputeJob).where( select(SoftwareJob).where(
ComputeJob.user_id == user_id, SoftwareJob.user_id == user_id,
ComputeJob.idempotency_key == key, SoftwareJob.idempotency_key == key,
) )
).scalar_one_or_none() ).scalar_one_or_none()
if existing is not None: if existing is not None:
@ -212,9 +216,9 @@ def create_job(
or existing.capability != capability or existing.capability != capability
or existing.request_digest != digest or existing.request_digest != digest
): ):
raise ComputeNodeError("idempotency key was already used for a different request") raise SoftwareJobError("idempotency key was already used for a different request")
return _job_dict(existing), False return _job_dict(existing), False
row = ComputeJob( row = SoftwareJob(
job_id=uuid4(), job_id=uuid4(),
user_id=user_id, user_id=user_id,
task_id=task_id, task_id=task_id,
@ -236,9 +240,9 @@ def create_job(
return _job_dict(row), True return _job_dict(row), True
except IntegrityError: except IntegrityError:
existing = session.execute( existing = session.execute(
select(ComputeJob).where( select(SoftwareJob).where(
ComputeJob.user_id == user_id, SoftwareJob.user_id == user_id,
ComputeJob.idempotency_key == key, SoftwareJob.idempotency_key == key,
) )
).scalar_one() ).scalar_one()
if ( if (
@ -246,7 +250,7 @@ def create_job(
or existing.capability != capability or existing.capability != capability
or existing.request_digest != digest or existing.request_digest != digest
): ):
raise ComputeNodeError( raise SoftwareJobError(
"idempotency key was already used for a different request" "idempotency key was already used for a different request"
) )
return _job_dict(existing), False return _job_dict(existing), False
@ -255,7 +259,7 @@ def create_job(
def get_job(user_id: UUID, job_id: UUID) -> dict | None: def get_job(user_id: UUID, job_id: UUID) -> dict | None:
with session_scope() as session: with session_scope() as session:
row = session.execute( row = session.execute(
select(ComputeJob).where(ComputeJob.job_id == job_id, ComputeJob.user_id == user_id) select(SoftwareJob).where(SoftwareJob.job_id == job_id, SoftwareJob.user_id == user_id)
).scalar_one_or_none() ).scalar_one_or_none()
return _job_dict(row) if row else None return _job_dict(row) if row else None
@ -267,10 +271,10 @@ def offer_next_job(node_ids: set[UUID]) -> dict | None:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
with session_scope() as session: with session_scope() as session:
expired = session.execute( expired = session.execute(
select(ComputeJob) select(SoftwareJob)
.where( .where(
ComputeJob.status == "offered", SoftwareJob.status == "offered",
ComputeJob.lease_expires_at <= now, SoftwareJob.lease_expires_at <= now,
) )
.with_for_update(skip_locked=True) .with_for_update(skip_locked=True)
).scalars() ).scalars()
@ -280,9 +284,9 @@ def offer_next_job(node_ids: set[UUID]) -> dict | None:
item.lease_id = None item.lease_id = None
item.lease_expires_at = None item.lease_expires_at = None
job = session.execute( job = session.execute(
select(ComputeJob) select(SoftwareJob)
.where(ComputeJob.status == "queued") .where(SoftwareJob.status == "queued")
.order_by(ComputeJob.created_at, ComputeJob.job_id) .order_by(SoftwareJob.created_at, SoftwareJob.job_id)
.with_for_update(skip_locked=True) .with_for_update(skip_locked=True)
.limit(1) .limit(1)
).scalar_one_or_none() ).scalar_one_or_none()
@ -290,16 +294,16 @@ def offer_next_job(node_ids: set[UUID]) -> dict | None:
return None return None
busy_node_ids = set( busy_node_ids = set(
session.execute( session.execute(
select(ComputeJob.node_id).where( select(SoftwareJob.node_id).where(
ComputeJob.node_id.is_not(None), SoftwareJob.node_id.is_not(None),
ComputeJob.status.in_({"offered", "dispatched", "running"}), SoftwareJob.status.in_({"offered", "dispatched", "running"}),
) )
).scalars() ).scalars()
) )
nodes = session.execute( nodes = session.execute(
select(ComputeNode) select(SoftwareNode)
.where(ComputeNode.node_id.in_(node_ids), ComputeNode.status == "online") .where(SoftwareNode.node_id.in_(node_ids), SoftwareNode.status == "online")
.order_by(ComputeNode.last_seen_at.desc()) .order_by(SoftwareNode.last_seen_at.desc())
).scalars() ).scalars()
node = next( node = next(
( (
@ -330,7 +334,7 @@ def offer_next_job(node_ids: set[UUID]) -> dict | None:
"request": job.request, "request": job.request,
"input_transfer": { "input_transfer": {
**job.input_manifest, **job.input_manifest,
"download_path": f"/v1/compute/jobs/{job.job_id}/input", "download_path": f"/v1/software-jobs/{job.job_id}/input",
}, },
}, },
} }
@ -340,10 +344,10 @@ def get_job_input(node_id: UUID, job_id: UUID) -> dict | None:
"""返回任务绑定的 artifact 定位信息;调用方仍需在 user_root 内安全解析。""" """返回任务绑定的 artifact 定位信息;调用方仍需在 user_root 内安全解析。"""
with session_scope() as session: with session_scope() as session:
job = session.execute( job = session.execute(
select(ComputeJob).where( select(SoftwareJob).where(
ComputeJob.job_id == job_id, SoftwareJob.job_id == job_id,
ComputeJob.node_id == node_id, SoftwareJob.node_id == node_id,
ComputeJob.status.in_({"offered", "dispatched", "running", "disconnected"}), SoftwareJob.status.in_({"offered", "dispatched", "running", "disconnected"}),
) )
).scalar_one_or_none() ).scalar_one_or_none()
if job is None: if job is None:
@ -369,9 +373,9 @@ def get_job_output_context(node_id: UUID, job_id: UUID, lease_id: UUID, digest:
"""返回 Node 输出上传上下文,不向 Node 暴露任何云端文件路径。""" """返回 Node 输出上传上下文,不向 Node 暴露任何云端文件路径。"""
with session_scope() as session: with session_scope() as session:
row = session.execute( row = session.execute(
select(ComputeJob, Task.working_dir) select(SoftwareJob, Task.working_dir)
.join(Task, Task.task_id == ComputeJob.task_id) .join(Task, Task.task_id == SoftwareJob.task_id)
.where(ComputeJob.job_id == job_id) .where(SoftwareJob.job_id == job_id)
).one_or_none() ).one_or_none()
if row is None: if row is None:
return None return None
@ -395,7 +399,7 @@ def get_job_output_context(node_id: UUID, job_id: UUID, lease_id: UUID, digest:
def validate_output_manifest(request: dict, manifest: object) -> list[dict]: def validate_output_manifest(request: dict, manifest: object) -> list[dict]:
if not isinstance(manifest, list): if not isinstance(manifest, list):
raise ComputeNodeError("job artifact manifest must be a list") raise SoftwareJobError("job artifact manifest must be a list")
requested_formats = set(request.get("output", {}).get("formats") or []) requested_formats = set(request.get("output", {}).get("formats") or [])
expected_ids = {"plot_spec", "provenance"} expected_ids = {"plot_spec", "provenance"}
expected_ids.update( expected_ids.update(
@ -404,7 +408,7 @@ def validate_output_manifest(request: dict, manifest: object) -> list[dict]:
if output_format in requested_formats if output_format in requested_formats
) )
if len(manifest) != len(expected_ids): if len(manifest) != len(expected_ids):
raise ComputeNodeError("job artifact manifest is incomplete") raise SoftwareJobError("job artifact manifest is incomplete")
normalized: list[dict] = [] normalized: list[dict] = []
seen: set[str] = set() seen: set[str] = set()
total = 0 total = 0
@ -412,24 +416,24 @@ def validate_output_manifest(request: dict, manifest: object) -> list[dict]:
if not isinstance(raw, dict) or set(raw) != { if not isinstance(raw, dict) or set(raw) != {
"artifact_id", "filename", "media_type", "size_bytes", "sha256" "artifact_id", "filename", "media_type", "size_bytes", "sha256"
}: }:
raise ComputeNodeError("job artifact manifest entry is invalid") raise SoftwareJobError("job artifact manifest entry is invalid")
local_id = raw.get("artifact_id") local_id = raw.get("artifact_id")
if local_id not in expected_ids or local_id in seen: if local_id not in expected_ids or local_id in seen:
raise ComputeNodeError("job artifact manifest identity is invalid") raise SoftwareJobError("job artifact manifest identity is invalid")
filename, media_type, _ = OUTPUT_ARTIFACTS[local_id] filename, media_type, _ = OUTPUT_ARTIFACTS[local_id]
size = raw.get("size_bytes") size = raw.get("size_bytes")
digest = raw.get("sha256") digest = raw.get("sha256")
if raw.get("filename") != filename or raw.get("media_type") != media_type: if raw.get("filename") != filename or raw.get("media_type") != media_type:
raise ComputeNodeError("job artifact manifest metadata does not match its identity") 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: if not isinstance(size, int) or isinstance(size, bool) or not 1 <= size <= MAX_OUTPUT_ARTIFACT_BYTES:
raise ComputeNodeError("job output artifact size is invalid") raise SoftwareJobError("job output artifact size is invalid")
if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest): if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
raise ComputeNodeError("job output artifact digest is invalid") raise SoftwareJobError("job output artifact digest is invalid")
total += size total += size
seen.add(local_id) seen.add(local_id)
normalized.append(dict(raw)) normalized.append(dict(raw))
if seen != expected_ids or total > MAX_OUTPUT_TOTAL_BYTES: if seen != expected_ids or total > MAX_OUTPUT_TOTAL_BYTES:
raise ComputeNodeError("job artifact manifest is incomplete or too large") raise SoftwareJobError("job artifact manifest is incomplete or too large")
return normalized return normalized
@ -442,7 +446,7 @@ def abandon_offer(node_id: UUID, payload: dict) -> None:
return return
with session_scope() as session: with session_scope() as session:
job = session.execute( job = session.execute(
select(ComputeJob).where(ComputeJob.job_id == job_id).with_for_update() select(SoftwareJob).where(SoftwareJob.job_id == job_id).with_for_update()
).scalar_one_or_none() ).scalar_one_or_none()
if ( if (
job is not None job is not None
@ -461,14 +465,14 @@ def respond_to_offer(node_id: UUID, *, accepted: bool, payload: dict) -> None:
job_id = UUID(str(payload.get("job_id", ""))) job_id = UUID(str(payload.get("job_id", "")))
lease_id = UUID(str(payload.get("lease_id", ""))) lease_id = UUID(str(payload.get("lease_id", "")))
except ValueError as exc: except ValueError as exc:
raise ComputeNodeError("invalid job offer response identity") from exc raise SoftwareJobError("invalid job offer response identity") from exc
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
with session_scope() as session: with session_scope() as session:
job = session.execute( job = session.execute(
select(ComputeJob).where(ComputeJob.job_id == job_id).with_for_update() select(SoftwareJob).where(SoftwareJob.job_id == job_id).with_for_update()
).scalar_one_or_none() ).scalar_one_or_none()
if job is None or job.node_id != node_id or job.lease_id != lease_id: if job is None or job.node_id != node_id or job.lease_id != lease_id:
raise ComputeNodeError("job offer is stale or does not belong to this node") raise SoftwareJobError("job offer is stale or does not belong to this node")
if ( if (
accepted accepted
and job.status in {"dispatched", "running", "succeeded", "failed", "cancelled"} and job.status in {"dispatched", "running", "succeeded", "failed", "cancelled"}
@ -476,16 +480,16 @@ def respond_to_offer(node_id: UUID, *, accepted: bool, payload: dict) -> None:
): ):
return return
if job.status != "offered": if job.status != "offered":
raise ComputeNodeError("job offer is stale or does not belong to this node") 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: if job.lease_expires_at is None or job.lease_expires_at <= now:
job.status = "queued" job.status = "queued"
job.node_id = None job.node_id = None
job.lease_id = None job.lease_id = None
job.lease_expires_at = None job.lease_expires_at = None
raise ComputeNodeError("job offer has expired") raise SoftwareJobError("job offer has expired")
if accepted: if accepted:
if payload.get("request_digest") != job.request_digest: if payload.get("request_digest") != job.request_digest:
raise ComputeNodeError("job request digest mismatch") raise SoftwareJobError("job request digest mismatch")
job.status = "dispatched" job.status = "dispatched"
job.stage = "accepted" job.stage = "accepted"
job.error = {} job.error = {}
@ -503,21 +507,21 @@ def update_job_state(node_id: UUID, payload: dict) -> None:
progress = payload.get("progress") progress = payload.get("progress")
metrics = payload.get("metrics") or {} metrics = payload.get("metrics") or {}
if not stage or len(stage) > 100: if not stage or len(stage) > 100:
raise ComputeNodeError("job stage is required") raise SoftwareJobError("job stage is required")
if not isinstance(progress, int) or isinstance(progress, bool) or not 0 <= progress <= 100: if not isinstance(progress, int) or isinstance(progress, bool) or not 0 <= progress <= 100:
raise ComputeNodeError("job progress must be between 0 and 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: if not isinstance(metrics, dict) or len(json.dumps(metrics, ensure_ascii=False)) > 64 * 1024:
raise ComputeNodeError("job metrics are invalid") raise SoftwareJobError("job metrics are invalid")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
with session_scope() as session: with session_scope() as session:
job = session.execute( job = session.execute(
select(ComputeJob).where(ComputeJob.job_id == job_id).with_for_update() select(SoftwareJob).where(SoftwareJob.job_id == job_id).with_for_update()
).scalar_one_or_none() ).scalar_one_or_none()
_assert_job_message(job, node_id, lease_id, digest) _assert_job_message(job, node_id, lease_id, digest)
if job.status in {"succeeded", "failed", "cancelled"}: if job.status in {"succeeded", "failed", "cancelled"}:
return return
if not _can_accept_state(job.status): if not _can_accept_state(job.status):
raise ComputeNodeError("job state cannot advance from its current status") raise SoftwareJobError("job state cannot advance from its current status")
job.status = ( job.status = (
"dispatched" "dispatched"
if stage in {"accepted", "waiting_input", "ready_to_run"} if stage in {"accepted", "waiting_input", "ready_to_run"}
@ -534,17 +538,17 @@ def record_job_terminal(node_id: UUID, payload: dict) -> None:
job_id, lease_id, digest = _message_identity(payload) job_id, lease_id, digest = _message_identity(payload)
terminal_status = payload.get("status") terminal_status = payload.get("status")
if terminal_status not in {"succeeded", "failed", "cancelled"}: if terminal_status not in {"succeeded", "failed", "cancelled"}:
raise ComputeNodeError("invalid job terminal status") raise SoftwareJobError("invalid job terminal status")
error = payload.get("error") or {} error = payload.get("error") or {}
manifest = payload.get("artifact_manifest") or [] manifest = payload.get("artifact_manifest") or []
if not isinstance(error, dict) or len(json.dumps(error, ensure_ascii=False)) > 64 * 1024: if not isinstance(error, dict) or len(json.dumps(error, ensure_ascii=False)) > 64 * 1024:
raise ComputeNodeError("job terminal error is invalid") raise SoftwareJobError("job terminal error is invalid")
if not isinstance(manifest, list) or len(json.dumps(manifest, ensure_ascii=False)) > 256 * 1024: if not isinstance(manifest, list) or len(json.dumps(manifest, ensure_ascii=False)) > 256 * 1024:
raise ComputeNodeError("job artifact manifest is invalid") raise SoftwareJobError("job artifact manifest is invalid")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
with session_scope() as session: with session_scope() as session:
job = session.execute( job = session.execute(
select(ComputeJob).where(ComputeJob.job_id == job_id).with_for_update() select(SoftwareJob).where(SoftwareJob.job_id == job_id).with_for_update()
).scalar_one_or_none() ).scalar_one_or_none()
_assert_job_message(job, node_id, lease_id, digest) _assert_job_message(job, node_id, lease_id, digest)
if terminal_status == "succeeded": if terminal_status == "succeeded":
@ -569,13 +573,13 @@ def record_job_terminal(node_id: UUID, payload: dict) -> None:
or not item["path"].startswith(f"origin/{job.job_id}/") or not item["path"].startswith(f"origin/{job.job_id}/")
for item in manifest for item in manifest
): ):
raise ComputeNodeError("successful job artifacts have not been published") raise SoftwareJobError("successful job artifacts have not been published")
if job.status in {"succeeded", "failed", "cancelled"}: if job.status in {"succeeded", "failed", "cancelled"}:
if job.status != terminal_status: if job.status != terminal_status:
raise ComputeNodeError("job terminal status conflicts with existing terminal") raise SoftwareJobError("job terminal status conflicts with existing terminal")
return return
if job.status not in {"offered", "dispatched", "running", "disconnected"}: if job.status not in {"offered", "dispatched", "running", "disconnected"}:
raise ComputeNodeError("job terminal cannot advance from its current status") raise SoftwareJobError("job terminal cannot advance from its current status")
job.status = terminal_status job.status = terminal_status
job.stage = "terminal" job.stage = "terminal"
job.progress = 100 if terminal_status == "succeeded" else job.progress job.progress = 100 if terminal_status == "succeeded" else job.progress
@ -596,10 +600,10 @@ def mark_node_jobs_disconnected(node_id: UUID) -> None:
"""连接丢失后保留 Node 归属和 lease禁止任务被自动重派。""" """连接丢失后保留 Node 归属和 lease禁止任务被自动重派。"""
with session_scope() as session: with session_scope() as session:
jobs = session.execute( jobs = session.execute(
select(ComputeJob) select(SoftwareJob)
.where( .where(
ComputeJob.node_id == node_id, SoftwareJob.node_id == node_id,
ComputeJob.status.in_({"dispatched", "running"}), SoftwareJob.status.in_({"dispatched", "running"}),
) )
.with_for_update() .with_for_update()
).scalars() ).scalars()
@ -616,15 +620,15 @@ def _message_identity(payload: dict) -> tuple[UUID, UUID, str]:
job_id = UUID(str(payload.get("job_id", ""))) job_id = UUID(str(payload.get("job_id", "")))
lease_id = UUID(str(payload.get("lease_id", ""))) lease_id = UUID(str(payload.get("lease_id", "")))
except ValueError as exc: except ValueError as exc:
raise ComputeNodeError("invalid job message identity") from exc raise SoftwareJobError("invalid job message identity") from exc
digest = str(payload.get("request_digest") or "") digest = str(payload.get("request_digest") or "")
if len(digest) != 64: if len(digest) != 64:
raise ComputeNodeError("invalid job request digest") raise SoftwareJobError("invalid job request digest")
return job_id, lease_id, digest return job_id, lease_id, digest
def _assert_job_message( def _assert_job_message(
job: ComputeJob | None, job: SoftwareJob | None,
node_id: UUID, node_id: UUID,
lease_id: UUID, lease_id: UUID,
digest: str, digest: str,
@ -635,4 +639,4 @@ def _assert_job_message(
or job.lease_id != lease_id or job.lease_id != lease_id
or job.request_digest != digest or job.request_digest != digest
): ):
raise ComputeNodeError("job message does not belong to this node or lease") raise SoftwareJobError("job message does not belong to this node or lease")

View File

@ -11,13 +11,13 @@ import bcrypt
from sqlalchemy import select from sqlalchemy import select
from core.storage.engine import session_scope from core.storage.engine import session_scope
from core.storage.models import ComputeNode, ComputeNodeEnrollment from core.storage.models import SoftwareNode, SoftwareNodeEnrollment
SUPPORTED_CAPABILITIES = frozenset({"origin.plot@v1"}) SUPPORTED_CAPABILITIES = frozenset({"origin.plot@v1"})
MAX_ENROLLMENT_FAILURES = 5 MAX_ENROLLMENT_FAILURES = 5
class ComputeNodeError(Exception): class SoftwareNodeError(Exception):
pass pass
@ -46,12 +46,12 @@ def create_enrollment(
) -> dict: ) -> dict:
allowed = list(dict.fromkeys(capabilities or ["origin.plot@v1"])) allowed = list(dict.fromkeys(capabilities or ["origin.plot@v1"]))
if not allowed or any(item not in SUPPORTED_CAPABILITIES for item in allowed): if not allowed or any(item not in SUPPORTED_CAPABILITIES for item in allowed):
raise ComputeNodeError("unsupported capability") raise SoftwareNodeError("unsupported capability")
if not 60 <= ttl_seconds <= 3600: if not 60 <= ttl_seconds <= 3600:
raise ComputeNodeError("ttl_seconds must be between 60 and 3600") raise SoftwareNodeError("ttl_seconds must be between 60 and 3600")
code = "ZCN-" + secrets.token_hex(16).upper() code = "ZCN-" + secrets.token_hex(16).upper()
expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds) expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
row = ComputeNodeEnrollment( row = SoftwareNodeEnrollment(
enrollment_id=uuid4(), enrollment_id=uuid4(),
code_hash=_enrollment_digest(code), code_hash=_enrollment_digest(code),
expected_name=(expected_name or "").strip() or None, expected_name=(expected_name or "").strip() or None,
@ -80,22 +80,22 @@ def enroll_node(
name = node_name.strip() name = node_name.strip()
requested = list(dict.fromkeys(capabilities)) requested = list(dict.fromkeys(capabilities))
if not name or not requested: if not name or not requested:
raise ComputeNodeError("node_name and capabilities are required") raise SoftwareNodeError("node_name and capabilities are required")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
error: str | None = None error: str | None = None
with session_scope() as session: with session_scope() as session:
enrollment = session.execute( enrollment = session.execute(
select(ComputeNodeEnrollment) select(SoftwareNodeEnrollment)
.where( .where(
ComputeNodeEnrollment.code_hash == _enrollment_digest(enrollment_code), SoftwareNodeEnrollment.code_hash == _enrollment_digest(enrollment_code),
ComputeNodeEnrollment.consumed_at.is_(None), SoftwareNodeEnrollment.consumed_at.is_(None),
ComputeNodeEnrollment.expires_at > now, SoftwareNodeEnrollment.expires_at > now,
ComputeNodeEnrollment.failed_attempts < MAX_ENROLLMENT_FAILURES, SoftwareNodeEnrollment.failed_attempts < MAX_ENROLLMENT_FAILURES,
) )
.with_for_update() .with_for_update()
).scalar_one_or_none() ).scalar_one_or_none()
if enrollment is None: if enrollment is None:
raise ComputeNodeError("invalid or expired enrollment code") raise SoftwareNodeError("invalid or expired enrollment code")
enrollment.failed_attempts += 1 enrollment.failed_attempts += 1
if enrollment.expected_name and enrollment.expected_name != name: if enrollment.expected_name and enrollment.expected_name != name:
error = "node name does not match enrollment" error = "node name does not match enrollment"
@ -103,7 +103,7 @@ def enroll_node(
error = "capability is not allowed by enrollment" error = "capability is not allowed by enrollment"
else: else:
existing = session.execute( existing = session.execute(
select(ComputeNode.node_id).where(ComputeNode.install_id == install_id) select(SoftwareNode.node_id).where(SoftwareNode.install_id == install_id)
).first() ).first()
if existing is not None: if existing is not None:
error = "install is already enrolled" error = "install is already enrolled"
@ -111,7 +111,7 @@ def enroll_node(
token = secrets.token_urlsafe(48) token = secrets.token_urlsafe(48)
node_id = uuid4() node_id = uuid4()
session.add( session.add(
ComputeNode( SoftwareNode(
node_id=node_id, node_id=node_id,
name=name, name=name,
install_id=install_id, install_id=install_id,
@ -125,7 +125,7 @@ def enroll_node(
) )
enrollment.consumed_at = now enrollment.consumed_at = now
if error is not None: if error is not None:
raise ComputeNodeError(error) raise SoftwareNodeError(error)
return { return {
"node_id": str(node_id), "node_id": str(node_id),
"node_token": token, "node_token": token,
@ -136,13 +136,13 @@ def enroll_node(
def authenticate_node(node_id: UUID, token: str) -> dict: def authenticate_node(node_id: UUID, token: str) -> dict:
with session_scope() as session: with session_scope() as session:
node = session.get(ComputeNode, node_id) node = session.get(SoftwareNode, node_id)
if ( if (
node is None node is None
or node.status == "disabled" or node.status == "disabled"
or not _verify_secret(token, node.token_hash) or not _verify_secret(token, node.token_hash)
): ):
raise ComputeNodeError("invalid node credentials") raise SoftwareNodeError("invalid node credentials")
return { return {
"node_id": node.node_id, "node_id": node.node_id,
"install_id": node.install_id, "install_id": node.install_id,
@ -152,9 +152,9 @@ def authenticate_node(node_id: UUID, token: str) -> dict:
def update_node_runtime(node_id: UUID, *, status: str, runtime: dict) -> None: def update_node_runtime(node_id: UUID, *, status: str, runtime: dict) -> None:
with session_scope() as session: with session_scope() as session:
node = session.get(ComputeNode, node_id) node = session.get(SoftwareNode, node_id)
if node is None or node.status == "disabled": if node is None or node.status == "disabled":
raise ComputeNodeError("node is disabled or missing") raise SoftwareNodeError("node is disabled or missing")
node.status = status node.status = status
node.runtime = runtime node.runtime = runtime
node.last_seen_at = datetime.now(timezone.utc) node.last_seen_at = datetime.now(timezone.utc)
@ -163,14 +163,14 @@ def update_node_runtime(node_id: UUID, *, status: str, runtime: dict) -> None:
def mark_node_offline(node_id: UUID) -> None: def mark_node_offline(node_id: UUID) -> None:
"""仅把活动节点转离线;管理员禁用态不可被断线收尾覆盖。""" """仅把活动节点转离线;管理员禁用态不可被断线收尾覆盖。"""
with session_scope() as session: with session_scope() as session:
node = session.get(ComputeNode, node_id) node = session.get(SoftwareNode, node_id)
if node is not None and node.status != "disabled": if node is not None and node.status != "disabled":
node.status = "offline" node.status = "offline"
def set_node_disabled(node_id: UUID, disabled: bool) -> bool: def set_node_disabled(node_id: UUID, disabled: bool) -> bool:
with session_scope() as session: with session_scope() as session:
node = session.get(ComputeNode, node_id) node = session.get(SoftwareNode, node_id)
if node is None: if node is None:
return False return False
node.status = "disabled" if disabled else "offline" node.status = "disabled" if disabled else "offline"
@ -180,7 +180,7 @@ def set_node_disabled(node_id: UUID, disabled: bool) -> bool:
def delete_node(node_id: UUID) -> bool: def delete_node(node_id: UUID) -> bool:
"""撤销并物理删除节点身份;当前节点表没有任务历史外键。""" """撤销并物理删除节点身份;当前节点表没有任务历史外键。"""
with session_scope() as session: with session_scope() as session:
node = session.get(ComputeNode, node_id) node = session.get(SoftwareNode, node_id)
if node is None: if node is None:
return False return False
session.delete(node) session.delete(node)
@ -190,7 +190,7 @@ def delete_node(node_id: UUID) -> bool:
def list_nodes() -> list[dict]: def list_nodes() -> list[dict]:
with session_scope() as session: with session_scope() as session:
rows = ( rows = (
session.execute(select(ComputeNode).order_by(ComputeNode.created_at)) session.execute(select(SoftwareNode).order_by(SoftwareNode.created_at))
.scalars() .scalars()
.all() .all()
) )

View File

@ -424,10 +424,10 @@ class ChannelBinding(Base):
) )
class ComputeNodeEnrollment(Base): class SoftwareNodeEnrollment(Base):
"""Windows Node 一次性注册码;数据库只保存不可逆摘要。""" """Windows Node 一次性注册码;数据库只保存不可逆摘要。"""
__tablename__ = "compute_node_enrollments" __tablename__ = "software_node_enrollments"
enrollment_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4) enrollment_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
code_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True) code_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
expected_name: Mapped[Optional[str]] = mapped_column(Text, nullable=True) expected_name: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
@ -443,10 +443,10 @@ class ComputeNodeEnrollment(Base):
) )
class ComputeNode(Base): class SoftwareNode(Base):
"""平台托管的 Windows 执行节点身份与最后一次运行态。""" """平台托管的 Windows 执行节点身份与最后一次运行态。"""
__tablename__ = "compute_nodes" __tablename__ = "software_nodes"
node_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4) node_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
name: Mapped[str] = mapped_column(Text, nullable=False) name: Mapped[str] = mapped_column(Text, nullable=False)
install_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False, unique=True) install_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False, unique=True)
@ -465,14 +465,14 @@ class ComputeNode(Base):
) )
class ComputeJob(Base): class SoftwareJob(Base):
"""受控计算任务账本;请求只保存规范化参数和输入引用。""" """专业软件任务账本;请求只保存规范化参数和输入引用。"""
__tablename__ = "compute_jobs" __tablename__ = "software_jobs"
__table_args__ = ( __table_args__ = (
UniqueConstraint("user_id", "idempotency_key", name="uq_compute_jobs_user_idempotency"), UniqueConstraint("user_id", "idempotency_key", name="uq_software_jobs_user_idempotency"),
Index("ix_compute_jobs_status_created", "status", "created_at"), Index("ix_software_jobs_status_created", "status", "created_at"),
Index("ix_compute_jobs_node_status", "node_id", "status"), Index("ix_software_jobs_node_status", "node_id", "status"),
) )
job_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4) job_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
@ -488,7 +488,7 @@ class ComputeJob(Base):
request_digest: Mapped[str] = mapped_column(Text, nullable=False) request_digest: Mapped[str] = mapped_column(Text, nullable=False)
input_manifest: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) input_manifest: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
node_id: Mapped[Optional[UUID]] = mapped_column( node_id: Mapped[Optional[UUID]] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("compute_nodes.node_id", ondelete="SET NULL"), nullable=True PG_UUID(as_uuid=True), ForeignKey("software_nodes.node_id", ondelete="SET NULL"), nullable=True
) )
lease_id: Mapped[Optional[UUID]] = mapped_column(PG_UUID(as_uuid=True), nullable=True) lease_id: Mapped[Optional[UUID]] = mapped_column(PG_UUID(as_uuid=True), nullable=True)
lease_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) lease_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)

View File

@ -1,52 +0,0 @@
"""Add the Windows compute job ledger.
Revision ID: 0032
Revises: 0031
Create Date: 2026-08-13
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0032"
down_revision: str | None = "0031"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"compute_jobs",
sa.Column("job_id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False),
sa.Column("task_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tasks.task_id", ondelete="CASCADE"), nullable=False),
sa.Column("idempotency_key", sa.Text(), nullable=False),
sa.Column("capability", sa.Text(), nullable=False),
sa.Column("request", postgresql.JSONB(), nullable=False),
sa.Column("request_digest", sa.Text(), nullable=False),
sa.Column("input_manifest", postgresql.JSONB(), nullable=False),
sa.Column("node_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("compute_nodes.node_id", ondelete="SET NULL"), nullable=True),
sa.Column("lease_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.Text(), server_default="queued", nullable=False),
sa.Column("stage", sa.Text(), server_default="", nullable=False),
sa.Column("progress", sa.Integer(), server_default="0", nullable=False),
sa.Column("metrics", postgresql.JSONB(), nullable=False),
sa.Column("error", postgresql.JSONB(), nullable=False),
sa.Column("artifact_manifest", postgresql.JSONB(), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("terminal_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("user_id", "idempotency_key", name="uq_compute_jobs_user_idempotency"),
)
op.create_index("ix_compute_jobs_status_created", "compute_jobs", ["status", "created_at"])
op.create_index("ix_compute_jobs_node_status", "compute_jobs", ["node_id", "status"])
def downgrade() -> None:
op.drop_index("ix_compute_jobs_node_status", table_name="compute_jobs")
op.drop_index("ix_compute_jobs_status_created", table_name="compute_jobs")
op.drop_table("compute_jobs")

View File

@ -0,0 +1,102 @@
"""Rename software nodes and add the professional software job ledger.
Revision ID: 0032
Revises: 0031
Create Date: 2026-08-13
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0032"
down_revision: str | None = "0031"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.rename_table("compute_node_enrollments", "software_node_enrollments")
op.rename_table("compute_nodes", "software_nodes")
op.execute(
"ALTER INDEX ix_compute_nodes_status RENAME TO ix_software_nodes_status"
)
op.execute(
"ALTER TABLE software_node_enrollments RENAME CONSTRAINT "
"compute_node_enrollments_pkey TO software_node_enrollments_pkey"
)
op.execute(
"ALTER TABLE software_node_enrollments RENAME CONSTRAINT "
"compute_node_enrollments_code_hash_key TO software_node_enrollments_code_hash_key"
)
op.execute(
"ALTER TABLE software_node_enrollments RENAME CONSTRAINT "
"compute_node_enrollments_created_by_fkey TO software_node_enrollments_created_by_fkey"
)
op.execute(
"ALTER TABLE software_nodes RENAME CONSTRAINT "
"compute_nodes_pkey TO software_nodes_pkey"
)
op.execute(
"ALTER TABLE software_nodes RENAME CONSTRAINT "
"compute_nodes_install_id_key TO software_nodes_install_id_key"
)
op.create_table(
"software_jobs",
sa.Column("job_id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False),
sa.Column("task_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tasks.task_id", ondelete="CASCADE"), nullable=False),
sa.Column("idempotency_key", sa.Text(), nullable=False),
sa.Column("capability", sa.Text(), nullable=False),
sa.Column("request", postgresql.JSONB(), nullable=False),
sa.Column("request_digest", sa.Text(), nullable=False),
sa.Column("input_manifest", postgresql.JSONB(), nullable=False),
sa.Column("node_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("software_nodes.node_id", ondelete="SET NULL"), nullable=True),
sa.Column("lease_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.Text(), server_default="queued", nullable=False),
sa.Column("stage", sa.Text(), server_default="", nullable=False),
sa.Column("progress", sa.Integer(), server_default="0", nullable=False),
sa.Column("metrics", postgresql.JSONB(), nullable=False),
sa.Column("error", postgresql.JSONB(), nullable=False),
sa.Column("artifact_manifest", postgresql.JSONB(), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("terminal_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("user_id", "idempotency_key", name="uq_software_jobs_user_idempotency"),
)
op.create_index("ix_software_jobs_status_created", "software_jobs", ["status", "created_at"])
op.create_index("ix_software_jobs_node_status", "software_jobs", ["node_id", "status"])
def downgrade() -> None:
op.drop_index("ix_software_jobs_node_status", table_name="software_jobs")
op.drop_index("ix_software_jobs_status_created", table_name="software_jobs")
op.drop_table("software_jobs")
op.execute(
"ALTER TABLE software_nodes RENAME CONSTRAINT "
"software_nodes_install_id_key TO compute_nodes_install_id_key"
)
op.execute(
"ALTER TABLE software_nodes RENAME CONSTRAINT "
"software_nodes_pkey TO compute_nodes_pkey"
)
op.execute(
"ALTER TABLE software_node_enrollments RENAME CONSTRAINT "
"software_node_enrollments_created_by_fkey TO compute_node_enrollments_created_by_fkey"
)
op.execute(
"ALTER TABLE software_node_enrollments RENAME CONSTRAINT "
"software_node_enrollments_code_hash_key TO compute_node_enrollments_code_hash_key"
)
op.execute(
"ALTER TABLE software_node_enrollments RENAME CONSTRAINT "
"software_node_enrollments_pkey TO compute_node_enrollments_pkey"
)
op.execute(
"ALTER INDEX ix_software_nodes_status RENAME TO ix_compute_nodes_status"
)
op.rename_table("software_nodes", "compute_nodes")
op.rename_table("software_node_enrollments", "compute_node_enrollments")

View File

@ -60,7 +60,7 @@ server {
# ★ Windows Node 长连接:注册走普通 POST,注册后的节点控制通道走 WebSocket。 # ★ Windows Node 长连接:注册走普通 POST,注册后的节点控制通道走 WebSocket。
# 必须独立于下面会清空 Connection 头的默认 location15s 应用心跳保持链路活跃。 # 必须独立于下面会清空 Connection 头的默认 location15s 应用心跳保持链路活跃。
location = /v1/compute/nodes/connect { location = /v1/software-nodes/connect {
proxy_pass http://zcbot_backend; proxy_pass http://zcbot_backend;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;

View File

@ -34,7 +34,7 @@ Windows Node 不是完整的本地 zcbot
### 2.1 目标 ### 2.1 目标
- 云端 zcbot 可以发现节点能力、容量、软件版本和在线状态。 - 云端 zcbot 可以发现节点能力、容量、软件版本和在线状态。
- 用户可以提交、查询、取消长时间 Windows 计算任务。 - 用户可以提交、查询、取消长时间运行的专业软件任务。
- 网络中断、云端重启或节点重启后,任务可以确定性对账。 - 网络中断、云端重启或节点重启后,任务可以确定性对账。
- 节点可以上报阶段、进度、结构化指标、日志摘要和事件截图。 - 节点可以上报阶段、进度、结构化指标、日志摘要和事件截图。
- 中间产物和最终产物支持校验、断点上传与按需导入工作目录。 - 中间产物和最终产物支持校验、断点上传与按需导入工作目录。
@ -79,7 +79,7 @@ flowchart LR
|---|---| |---|---|
| `NodeRegistry` | 节点注册、证书指纹、启停、能力和管理员标签 | | `NodeRegistry` | 节点注册、证书指纹、启停、能力和管理员标签 |
| `NodeConnectionManager` | WSS 连接、心跳、消息 ACK、同节点单活连接 | | `NodeConnectionManager` | WSS 连接、心跳、消息 ACK、同节点单活连接 |
| `ComputeJobService` | 用户授权、幂等提交、节点选择、租约、取消和终态 | | `SoftwareJobService` | 用户授权、幂等提交、节点选择、租约、取消和终态 |
| `ComputeTransferService` | 输入下载凭证、分块上传、SHA-256、容量与保留期 | | `ComputeTransferService` | 输入下载凭证、分块上传、SHA-256、容量与保留期 |
| `ComputeBroker` | 把任务事件推送到 Web UI不承担持久化事实源 | | `ComputeBroker` | 把任务事件推送到 Web UI不承担持久化事实源 |
| `ComputeTools` | agent 可调用的能力发现、提交、查询、取消、产物导入工具 | | `ComputeTools` | agent 可调用的能力发现、提交、查询、取消、产物导入工具 |
@ -157,7 +157,7 @@ sequenceDiagram
participant Node as Windows Node participant Node as Windows Node
Admin->>Cloud: 创建一次性 enrollment token Admin->>Cloud: 创建一次性 enrollment token
Node->>Node: 生成设备密钥对 Node->>Node: 生成设备密钥对
Node->>Cloud: POST /v1/compute/nodes/enroll Node->>Cloud: POST /v1/software-nodes/enroll
Cloud->>Cloud: 消耗 token创建 node_id Cloud->>Cloud: 消耗 token创建 node_id
Cloud-->>Node: 客户端证书、CA、云端地址 Cloud-->>Node: 客户端证书、CA、云端地址
Node->>Node: 私钥写入 Windows Certificate Store Node->>Node: 私钥写入 Windows Certificate Store
@ -178,7 +178,7 @@ sequenceDiagram
节点连接: 节点连接:
```text ```text
WSS /v1/compute/nodes/connect WSS /v1/software-nodes/connect
``` ```
统一消息 envelope 统一消息 envelope
@ -252,13 +252,13 @@ Node 在 `hello` 和心跳中声明由本机可信配置生成的能力:
建议新增三张表,不复用外部系统连接表: 建议新增三张表,不复用外部系统连接表:
```text ```text
compute_nodes( software_nodes(
node_id pk, name, cert_fingerprint, status, node_id pk, name, cert_fingerprint, status,
labels jsonb, capabilities jsonb, config_revision, labels jsonb, capabilities jsonb, config_revision,
last_seen_at, disabled_at, created_at, updated_at last_seen_at, disabled_at, created_at, updated_at
) )
compute_jobs( software_jobs(
job_id pk, user_id fk, task_id fk, tool_call_id, job_id pk, user_id fk, task_id fk, tool_call_id,
capability, schema_version, request jsonb, capability, schema_version, request jsonb,
idempotency_key, request_digest, idempotency_key, request_digest,
@ -268,7 +268,7 @@ compute_jobs(
created_at, started_at, terminal_at, updated_at created_at, started_at, terminal_at, updated_at
) )
compute_job_events( software_job_events(
event_id pk, job_id fk, sequence, event_id pk, job_id fk, sequence,
kind, level, payload jsonb, created_at kind, level, payload jsonb, created_at
) )
@ -352,7 +352,7 @@ Node 不访问整个用户 workspace。云端只为显式引用的文件创建
大产物不通过 WSS 消息传输。Node 使用 HTTPS 分块上传: 大产物不通过 WSS 消息传输。Node 使用 HTTPS 分块上传:
```text ```text
POST /v1/compute/jobs/{job_id}/artifacts/upload-session POST /v1/software-jobs/{job_id}/artifacts/upload-session
PUT /v1/compute/transfers/{transfer_id}/parts/{part_number} PUT /v1/compute/transfers/{transfer_id}/parts/{part_number}
POST /v1/compute/transfers/{transfer_id}/complete POST /v1/compute/transfers/{transfer_id}/complete
``` ```
@ -364,7 +364,7 @@ POST /v1/compute/transfers/{transfer_id}/complete
Node 上传完成后先进入: Node 上传完成后先进入:
```text ```text
<user_root>/.zcbot_cache/<task_id>/compute_jobs/<job_id>/ <user_root>/.zcbot_cache/<task_id>/software_jobs/<job_id>/
``` ```
该目录默认隐藏且有 TTL。用户或 agent 明确导入后复制到: 该目录默认隐藏且有 TTL。用户或 agent 明确导入后复制到:
@ -379,10 +379,10 @@ Node 上传完成后先进入:
```text ```text
compute_capability_list compute_capability_list
compute_job_submit software_job_submit
compute_job_status software_job_status
compute_job_cancel software_job_cancel
compute_job_artifact_import software_job_artifact_import
``` ```
- capability list 只返回用户有权使用且有健康节点承载的能力; - capability list 只返回用户有权使用且有健康节点承载的能力;
@ -418,7 +418,7 @@ process.cancel_adapter
- 优先捕获目标窗口; - 优先捕获目标窗口;
- 敏感信息上传前遮罩; - 敏感信息上传前遮罩;
- 视频默认关闭,显式启用时建议 1280×720、510 FPS、H.264 分段; - 视频默认关闭,显式启用时建议 1280×720、510 FPS、H.264 分段;
- 采集失败不得使计算任务失败; - 采集失败不得使专业软件任务失败;
- 模型只按需读取关键帧,不持续消费完整视频。 - 模型只按需读取关键帧,不持续消费完整视频。
## 12. Origin 首批适配器 ## 12. Origin 首批适配器

View File

@ -60,7 +60,7 @@ sequenceDiagram
participant N as Windows Node participant N as Windows Node
A->>Z: 创建一次性注册码 A->>Z: 创建一次性注册码
A->>N: 输入内网地址和注册码 A->>N: 输入内网地址和注册码
N->>Z: HTTP POST /v1/compute/nodes/enroll N->>Z: HTTP POST /v1/software-nodes/enroll
Z->>Z: 校验并原子消费注册码 Z->>Z: 校验并原子消费注册码
Z-->>N: node_id + node_token + 配置 Z-->>N: node_id + node_token + 配置
N->>N: DPAPI 加密保存 node_token N->>N: DPAPI 加密保存 node_token
@ -71,7 +71,7 @@ sequenceDiagram
注册请求: 注册请求:
```http ```http
POST http://zcbot.internal:8765/v1/compute/nodes/enroll POST http://zcbot.internal:8765/v1/software-nodes/enroll
Content-Type: application/json Content-Type: application/json
``` ```
@ -116,7 +116,7 @@ Content-Type: application/json
### 4.1 WS 连接 ### 4.1 WS 连接
```http ```http
GET ws://zcbot.internal:8765/v1/compute/nodes/connect GET ws://zcbot.internal:8765/v1/software-nodes/connect
Authorization: Bearer <node_token> Authorization: Bearer <node_token>
X-Node-Id: <node_id> X-Node-Id: <node_id>
Upgrade: websocket Upgrade: websocket
@ -170,13 +170,13 @@ RDP不向公网开放使用 VPN、堡垒机或云安全登录
云端首期只增加: 云端首期只增加:
```text ```text
compute_nodes( software_nodes(
node_id pk, name, install_id, token_hash, status, node_id pk, name, install_id, token_hash, status,
capabilities jsonb, last_seen_at, capabilities jsonb, last_seen_at,
created_at, updated_at created_at, updated_at
) )
compute_jobs( software_jobs(
job_id pk, user_id fk, task_id fk, job_id pk, user_id fk, task_id fk,
idempotency_key, capability, request jsonb, idempotency_key, capability, request jsonb,
node_id fk, status, progress, node_id fk, status, progress,

View File

@ -11,13 +11,13 @@ from alembic.operations import Operations
from sqlalchemy import create_mock_engine from sqlalchemy import create_mock_engine
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
from core.compute_nodes import ( from core.software_nodes import (
_enrollment_digest, _enrollment_digest,
_hash_secret, _hash_secret,
_verify_secret, _verify_secret,
delete_node, delete_node,
) )
from core.compute_jobs import ( from core.software_jobs import (
_canonical_request, _canonical_request,
abandon_offer, abandon_offer,
mark_node_jobs_disconnected, mark_node_jobs_disconnected,
@ -26,10 +26,10 @@ from core.compute_jobs import (
update_job_state, update_job_state,
validate_output_manifest, validate_output_manifest,
) )
from web.routers.compute_nodes import NodeConnectionManager, _bearer from web.routers.software_nodes import NodeConnectionManager, _bearer
class ComputeNodeSecurityTests(unittest.TestCase): class SoftwareNodeSecurityTests(unittest.TestCase):
def test_secret_hash_is_salted_and_verifiable(self) -> None: def test_secret_hash_is_salted_and_verifiable(self) -> None:
first = _hash_secret("node-secret") first = _hash_secret("node-secret")
second = _hash_secret("node-secret") second = _hash_secret("node-secret")
@ -50,9 +50,9 @@ class ComputeNodeSecurityTests(unittest.TestCase):
def test_websocket_auth_rejection_uses_explicit_application_close_code(self) -> None: def test_websocket_auth_rejection_uses_explicit_application_close_code(self) -> None:
source = ( source = (
Path(__file__).resolve().parents[1] / "web" / "routers" / "compute_nodes.py" Path(__file__).resolve().parents[1] / "web" / "routers" / "software_nodes.py"
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
rejection = source.split("except (ValueError, ComputeNodeError):", 1)[1].split( rejection = source.split("except (ValueError, SoftwareNodeError):", 1)[1].split(
"await node_connections.activate", 1 "await node_connections.activate", 1
)[0] )[0]
self.assertLess( self.assertLess(
@ -61,7 +61,7 @@ class ComputeNodeSecurityTests(unittest.TestCase):
self.assertIn('code=4003, reason="invalid node credentials"', rejection) self.assertIn('code=4003, reason="invalid node credentials"', rejection)
class ComputeNodeConnectionTests(unittest.IsolatedAsyncioTestCase): class SoftwareNodeConnectionTests(unittest.IsolatedAsyncioTestCase):
async def test_new_connection_replaces_old_without_removing_new(self) -> None: async def test_new_connection_replaces_old_without_removing_new(self) -> None:
manager = NodeConnectionManager() manager = NodeConnectionManager()
node_id = uuid4() node_id = uuid4()
@ -87,7 +87,7 @@ class ComputeNodeConnectionTests(unittest.IsolatedAsyncioTestCase):
self.assertFalse(await manager.remove(node_id, websocket)) self.assertFalse(await manager.remove(node_id, websocket))
class ComputeNodeMigrationTests(unittest.TestCase): class SoftwareNodeMigrationTests(unittest.TestCase):
def test_0030_upgrade_compiles_as_postgresql_ddl(self) -> None: def test_0030_upgrade_compiles_as_postgresql_ddl(self) -> None:
statements: list[str] = [] statements: list[str] = []
@ -116,18 +116,20 @@ class ComputeNodeMigrationTests(unittest.TestCase):
engine = create_mock_engine("postgresql+psycopg://", capture) engine = create_mock_engine("postgresql+psycopg://", capture)
operations = Operations(MigrationContext.configure(engine.connect())) operations = Operations(MigrationContext.configure(engine.connect()))
migration = importlib.import_module( migration = importlib.import_module(
"db.migrations.versions.20260813_1600_0032_compute_jobs" "db.migrations.versions.20260813_1600_0032_software_jobs"
) )
with patch.object(migration, "op", operations): with patch.object(migration, "op", operations):
migration.upgrade() migration.upgrade()
rendered = "\n".join(statements) rendered = "\n".join(statements)
self.assertIn("compute_jobs", rendered) self.assertIn("ALTER TABLE compute_node_enrollments RENAME TO software_node_enrollments", rendered)
self.assertIn("uq_compute_jobs_user_idempotency", rendered) self.assertIn("ALTER TABLE compute_nodes RENAME TO software_nodes", rendered)
self.assertIn("ix_compute_jobs_status_created", rendered) self.assertIn("software_jobs", rendered)
self.assertIn("uq_software_jobs_user_idempotency", rendered)
self.assertIn("ix_software_jobs_status_created", rendered)
class ComputeJobProtocolTests(unittest.TestCase): class SoftwareJobProtocolTests(unittest.TestCase):
def test_origin_request_is_canonical_and_rejects_extra_fields(self) -> None: def test_origin_request_is_canonical_and_rejects_extra_fields(self) -> None:
request = { request = {
"schema_version": 1, "schema_version": 1,
@ -182,7 +184,7 @@ class ComputeJobProtocolTests(unittest.TestCase):
[{**manifest[0], "filename": "anything.opju"}, *manifest[1:]], [{**manifest[0], "filename": "anything.opju"}, *manifest[1:]],
) )
@patch("core.compute_jobs.session_scope") @patch("core.software_jobs.session_scope")
def test_stale_offer_cannot_be_accepted_by_another_node(self, session_scope) -> None: def test_stale_offer_cannot_be_accepted_by_another_node(self, session_scope) -> None:
session = session_scope.return_value.__enter__.return_value session = session_scope.return_value.__enter__.return_value
job = type("Job", (), {})() job = type("Job", (), {})()
@ -197,7 +199,7 @@ class ComputeJobProtocolTests(unittest.TestCase):
payload={"job_id": str(uuid4()), "lease_id": str(job.lease_id)}, payload={"job_id": str(uuid4()), "lease_id": str(job.lease_id)},
) )
@patch("core.compute_jobs.session_scope") @patch("core.software_jobs.session_scope")
def test_failed_delivery_only_abandons_matching_offer(self, session_scope) -> None: def test_failed_delivery_only_abandons_matching_offer(self, session_scope) -> None:
session = session_scope.return_value.__enter__.return_value session = session_scope.return_value.__enter__.return_value
node_id = uuid4() node_id = uuid4()
@ -216,7 +218,7 @@ class ComputeJobProtocolTests(unittest.TestCase):
def test_dispatcher_excludes_nodes_with_active_jobs(self) -> None: def test_dispatcher_excludes_nodes_with_active_jobs(self) -> None:
source = ( source = (
Path(__file__).resolve().parents[1] / "core" / "compute_jobs.py" Path(__file__).resolve().parents[1] / "core" / "software_jobs.py"
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
self.assertIn('{"offered", "dispatched", "running"}', source) self.assertIn('{"offered", "dispatched", "running"}', source)
self.assertIn("item.node_id not in busy_node_ids", source) self.assertIn("item.node_id not in busy_node_ids", source)
@ -224,12 +226,12 @@ class ComputeJobProtocolTests(unittest.TestCase):
def test_input_download_rechecks_file_digest(self) -> None: def test_input_download_rechecks_file_digest(self) -> None:
source = ( source = (
Path(__file__).resolve().parents[1] Path(__file__).resolve().parents[1]
/ "web" / "routers" / "compute_nodes.py" / "web" / "routers" / "software_nodes.py"
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
self.assertIn("digest = sha256()", source) self.assertIn("digest = sha256()", source)
self.assertIn('digest.hexdigest() != item["sha256"]', source) self.assertIn('digest.hexdigest() != item["sha256"]', source)
@patch("core.compute_jobs.session_scope") @patch("core.software_jobs.session_scope")
def test_job_state_restores_disconnected_job(self, session_scope) -> None: def test_job_state_restores_disconnected_job(self, session_scope) -> None:
session = session_scope.return_value.__enter__.return_value session = session_scope.return_value.__enter__.return_value
node_id = uuid4() node_id = uuid4()
@ -253,7 +255,7 @@ class ComputeJobProtocolTests(unittest.TestCase):
self.assertEqual(job.status, "dispatched") self.assertEqual(job.status, "dispatched")
self.assertEqual(job.stage, "waiting_input") self.assertEqual(job.stage, "waiting_input")
@patch("core.compute_jobs.session_scope") @patch("core.software_jobs.session_scope")
def test_ready_to_run_is_not_reported_as_running(self, session_scope) -> None: def test_ready_to_run_is_not_reported_as_running(self, session_scope) -> None:
session = session_scope.return_value.__enter__.return_value session = session_scope.return_value.__enter__.return_value
node_id = uuid4() node_id = uuid4()
@ -273,7 +275,7 @@ class ComputeJobProtocolTests(unittest.TestCase):
}) })
self.assertEqual(job.status, "dispatched") self.assertEqual(job.status, "dispatched")
@patch("core.compute_jobs.session_scope") @patch("core.software_jobs.session_scope")
def test_terminal_replay_is_idempotent(self, session_scope) -> None: def test_terminal_replay_is_idempotent(self, session_scope) -> None:
session = session_scope.return_value.__enter__.return_value session = session_scope.return_value.__enter__.return_value
node_id = uuid4() node_id = uuid4()
@ -295,7 +297,7 @@ class ComputeJobProtocolTests(unittest.TestCase):
}) })
self.assertEqual(job.status, "failed") self.assertEqual(job.status, "failed")
@patch("core.compute_jobs.session_scope") @patch("core.software_jobs.session_scope")
def test_disconnect_does_not_requeue_active_jobs(self, session_scope) -> None: def test_disconnect_does_not_requeue_active_jobs(self, session_scope) -> None:
session = session_scope.return_value.__enter__.return_value session = session_scope.return_value.__enter__.return_value
first = type("Job", (), {"status": "running"})() first = type("Job", (), {"status": "running"})()
@ -306,8 +308,8 @@ class ComputeJobProtocolTests(unittest.TestCase):
self.assertEqual(second.status, "disconnected") self.assertEqual(second.status, "disconnected")
class ComputeNodeDeleteTests(unittest.TestCase): class SoftwareNodeDeleteTests(unittest.TestCase):
@patch("core.compute_nodes.session_scope") @patch("core.software_nodes.session_scope")
def test_delete_node_removes_existing_identity(self, session_scope) -> None: def test_delete_node_removes_existing_identity(self, session_scope) -> None:
session = session_scope.return_value.__enter__.return_value session = session_scope.return_value.__enter__.return_value
node = object() node = object()
@ -316,7 +318,7 @@ class ComputeNodeDeleteTests(unittest.TestCase):
self.assertTrue(delete_node(uuid4())) self.assertTrue(delete_node(uuid4()))
session.delete.assert_called_once_with(node) session.delete.assert_called_once_with(node)
@patch("core.compute_nodes.session_scope") @patch("core.software_nodes.session_scope")
def test_delete_node_reports_missing_identity(self, session_scope) -> None: def test_delete_node_reports_missing_identity(self, session_scope) -> None:
session = session_scope.return_value.__enter__.return_value session = session_scope.return_value.__enter__.return_value
session.get.return_value = None session.get.return_value = None

View File

@ -7,16 +7,16 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from uuid import uuid4 from uuid import uuid4
from web.routers.compute_nodes import _publish_compute_outputs from web.routers.software_nodes import _publish_software_job_outputs
class ComputeOutputPublishTests(unittest.TestCase): class SoftwareOutputPublishTests(unittest.TestCase):
def test_complete_set_moves_atomically_and_can_be_replayed(self) -> None: def test_complete_set_moves_atomically_and_can_be_replayed(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
job_id = uuid4() job_id = uuid4()
working_dir = root / "research" working_dir = root / "research"
staging = root / ".zcbot_compute_staging" / str(job_id) staging = root / ".zcbot_software_job_staging" / str(job_id)
staging.mkdir(parents=True) staging.mkdir(parents=True)
working_dir.mkdir() working_dir.mkdir()
content = b"origin-result" content = b"origin-result"
@ -44,14 +44,14 @@ class ComputeOutputPublishTests(unittest.TestCase):
} for ref in kwargs["refs"]) } for ref in kwargs["refs"])
with ( with (
patch("web.routers.compute_nodes.load_user_root", return_value=root), patch("web.routers.software_nodes.load_user_root", return_value=root),
patch( patch(
"web.routers.compute_nodes.register_published_artifacts", "web.routers.software_nodes.register_published_artifacts",
side_effect=register, side_effect=register,
), ),
): ):
first = _publish_compute_outputs(job_id, context, manifest) first = _publish_software_job_outputs(job_id, context, manifest)
second = _publish_compute_outputs(job_id, context, manifest) second = _publish_software_job_outputs(job_id, context, manifest)
published = working_dir / "origin" / str(job_id) / "figure.png" published = working_dir / "origin" / str(job_id) / "figure.png"
self.assertEqual(published.read_bytes(), content) self.assertEqual(published.read_bytes(), content)

View File

@ -32,14 +32,14 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn('id="node-enrollment-modal" class="modal"', html) self.assertIn('id="node-enrollment-modal" class="modal"', html)
self.assertIn("生成 Windows Node 注册码", html) self.assertIn("生成 Windows Node 注册码", html)
self.assertIn('"/v1/admin/compute-node-enrollments"', admin_js) self.assertIn('"/v1/admin/software-node-enrollments"', admin_js)
self.assertIn('capabilities: ["origin.plot@v1"]', admin_js) self.assertIn('capabilities: ["origin.plot@v1"]', admin_js)
self.assertIn('origin.health === "ready"', admin_js) self.assertIn('origin.health === "ready"', admin_js)
self.assertIn("ttl_seconds: 600", admin_js) self.assertIn("ttl_seconds: 600", admin_js)
self.assertIn("navigator.clipboard.writeText(value)", admin_js) self.assertIn("navigator.clipboard.writeText(value)", admin_js)
self.assertIn('apiGet("/v1/admin/compute-nodes")', admin_js) self.assertIn('apiGet("/v1/admin/software-nodes")', admin_js)
self.assertIn('apiSend("PATCH", `/v1/admin/compute-nodes/${node.node_id}`', admin_js) self.assertIn('apiSend("PATCH", `/v1/admin/software-nodes/${node.node_id}`', admin_js)
self.assertIn('apiSend("DELETE", `/v1/admin/compute-nodes/${node.node_id}`', admin_js) self.assertIn('apiSend("DELETE", `/v1/admin/software-nodes/${node.node_id}`', admin_js)
self.assertIn("最近心跳", admin_js) self.assertIn("最近心跳", admin_js)
self.assertIn("重新启用", admin_js) self.assertIn("重新启用", admin_js)
self.assertIn("永久删除", admin_js) self.assertIn("永久删除", admin_js)

View File

@ -110,8 +110,8 @@ class AuthGateTests(unittest.TestCase):
("POST", "/v1/tasks"), ("POST", "/v1/tasks"),
("POST", "/v1/asr/transcribe"), ("POST", "/v1/asr/transcribe"),
("GET", "/v1/admin/overview"), ("GET", "/v1/admin/overview"),
("GET", "/v1/admin/compute-nodes"), ("GET", "/v1/admin/software-nodes"),
("DELETE", "/v1/admin/compute-nodes/00000000-0000-0000-0000-000000000000"), ("DELETE", "/v1/admin/software-nodes/00000000-0000-0000-0000-000000000000"),
("GET", "/v1/admin/tool-wire-health"), ("GET", "/v1/admin/tool-wire-health"),
("GET", "/v1/admin/external-system-definitions"), ("GET", "/v1/admin/external-system-definitions"),
("GET", "/v1/admin/external-system-users"), ("GET", "/v1/admin/external-system-users"),

View File

@ -20,8 +20,8 @@ class WindowsNodeSourceTests(unittest.TestCase):
def test_node_protocol_and_secret_storage_markers_are_present(self) -> None: def test_node_protocol_and_secret_storage_markers_are_present(self) -> None:
source = "\n".join(path.read_text(encoding="utf-8") for path in PROJECT.glob("*.cs")) source = "\n".join(path.read_text(encoding="utf-8") for path in PROJECT.glob("*.cs"))
for marker in ( for marker in (
"v1/compute/nodes/enroll", "v1/software-nodes/enroll",
"v1/compute/nodes/connect", "v1/software-nodes/connect",
'SetRequestHeader("Authorization"', 'SetRequestHeader("Authorization"',
'SetRequestHeader("X-Node-Id"', 'SetRequestHeader("X-Node-Id"',
"DataProtectionScope.LocalMachine", "DataProtectionScope.LocalMachine",

View File

@ -49,7 +49,7 @@ from .background import (
from .broker import broker from .broker import broker
from .routers.asr import register_asr_routes from .routers.asr import register_asr_routes
from .routers.authroutes import register_auth_routes from .routers.authroutes import register_auth_routes
from .routers.compute_nodes import register_compute_node_routes from .routers.software_nodes import register_software_node_routes
from .routers.external_systems import register_external_system_routes from .routers.external_systems import register_external_system_routes
from .routers.files import register_file_routes from .routers.files import register_file_routes
from .routers.kb import register_kb_routes from .routers.kb import register_kb_routes
@ -206,7 +206,7 @@ def create_app() -> FastAPI:
register_asr_routes(app, require_user=require_user, auth_cfg=auth_cfg) register_asr_routes(app, require_user=require_user, auth_cfg=auth_cfg)
register_task_routes(app, require_user=require_user) register_task_routes(app, require_user=require_user)
register_message_routes(app, require_user=require_user) register_message_routes(app, require_user=require_user)
register_compute_node_routes( register_software_node_routes(
app, require_user=require_user, require_admin=require_admin app, require_user=require_user, require_admin=require_admin
) )

View File

@ -11,8 +11,8 @@ from uuid import UUID
from fastapi import Depends, Header, HTTPException, Request, WebSocket, WebSocketDisconnect, status from fastapi import Depends, Header, HTTPException, Request, WebSocket, WebSocketDisconnect, status
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from core.compute_nodes import ( from core.software_nodes import (
ComputeNodeError, SoftwareNodeError,
authenticate_node, authenticate_node,
create_enrollment, create_enrollment,
delete_node, delete_node,
@ -22,7 +22,7 @@ from core.compute_nodes import (
set_node_disabled, set_node_disabled,
update_node_runtime, update_node_runtime,
) )
from core.compute_jobs import ( from core.software_jobs import (
MAX_OUTPUT_ARTIFACT_BYTES, MAX_OUTPUT_ARTIFACT_BYTES,
MAX_OUTPUT_TOTAL_BYTES, MAX_OUTPUT_TOTAL_BYTES,
OUTPUT_ARTIFACTS, OUTPUT_ARTIFACTS,
@ -37,13 +37,14 @@ from core.compute_jobs import (
respond_to_offer, respond_to_offer,
update_job_state, update_job_state,
validate_output_manifest, validate_output_manifest,
SoftwareJobError,
) )
from core.artifact_lifecycle import register_published_artifacts from core.artifact_lifecycle import register_published_artifacts
from web.schemas import ( from web.schemas import (
ComputeEnrollmentCreateRequest, SoftwareEnrollmentCreateRequest,
ComputeJobCreateRequest, SoftwareJobCreateRequest,
ComputeNodeDisableRequest, SoftwareNodeDisableRequest,
ComputeNodeEnrollRequest, SoftwareNodeEnrollRequest,
) )
from web.userfiles import load_user_root, safe_join from web.userfiles import load_user_root, safe_join
@ -108,7 +109,7 @@ node_connections = NodeConnectionManager()
def _bearer(authorization: str | None) -> str: def _bearer(authorization: str | None) -> str:
scheme, _, token = (authorization or "").partition(" ") scheme, _, token = (authorization or "").partition(" ")
if scheme.lower() != "bearer" or not token: if scheme.lower() != "bearer" or not token:
raise ComputeNodeError("missing node bearer token") raise SoftwareNodeError("missing node bearer token")
return token return token
@ -123,11 +124,11 @@ def _authenticate_output_request(
node_id = UUID(x_node_id) node_id = UUID(x_node_id)
lease_id = UUID(x_lease_id) lease_id = UUID(x_lease_id)
authenticate_node(node_id, _bearer(authorization)) authenticate_node(node_id, _bearer(authorization))
except (ValueError, ComputeNodeError) as exc: except (ValueError, SoftwareNodeError) as exc:
raise HTTPException(401, "invalid node credentials or job identity") from exc raise HTTPException(401, "invalid node credentials or job identity") from exc
context = get_job_output_context(node_id, job_id, lease_id, x_request_digest) context = get_job_output_context(node_id, job_id, lease_id, x_request_digest)
if context is None: if context is None:
raise HTTPException(404, "compute job output target not found") raise HTTPException(404, "software job output target not found")
return node_id, lease_id, context return node_id, lease_id, context
@ -145,13 +146,13 @@ def _reject_symlink_path(root: Path, target: Path) -> None:
for part in target.relative_to(root).parts: for part in target.relative_to(root).parts:
current = current / part current = current / part
if current.is_symlink(): if current.is_symlink():
raise HTTPException(409, "compute output path contains a symbolic link") raise HTTPException(409, "software job output path contains a symbolic link")
def _publish_compute_outputs(job_id: UUID, context: dict, manifest: list[dict]) -> list[dict]: def _publish_software_job_outputs(job_id: UUID, context: dict, manifest: list[dict]) -> list[dict]:
root = load_user_root(context["user_id"]) root = load_user_root(context["user_id"])
working_dir = safe_join(root, context["working_dir"]) working_dir = safe_join(root, context["working_dir"])
staging = safe_join(root, f".zcbot_compute_staging/{job_id}") staging = safe_join(root, f".zcbot_software_job_staging/{job_id}")
relative_output = Path("origin") / str(job_id) relative_output = Path("origin") / str(job_id)
destination = safe_join(working_dir, relative_output.as_posix()) destination = safe_join(working_dir, relative_output.as_posix())
source = staging if staging.is_dir() else destination source = staging if staging.is_dir() else destination
@ -164,11 +165,11 @@ def _publish_compute_outputs(job_id: UUID, context: dict, manifest: list[dict])
or path.stat().st_size != item["size_bytes"] or path.stat().st_size != item["size_bytes"]
or _hash_file(path) != item["sha256"] or _hash_file(path) != item["sha256"]
): ):
raise ComputeNodeError(f"uploaded artifact is missing or invalid: {item['artifact_id']}") raise SoftwareJobError(f"uploaded artifact is missing or invalid: {item['artifact_id']}")
if source == staging: if source == staging:
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
if destination.exists(): if destination.exists():
raise ComputeNodeError("compute output destination already exists unexpectedly") raise SoftwareJobError("software job output destination already exists unexpectedly")
os.replace(staging, destination) os.replace(staging, destination)
try: try:
staging.parent.rmdir() staging.parent.rmdir()
@ -198,20 +199,20 @@ def _publish_compute_outputs(job_id: UUID, context: dict, manifest: list[dict])
] ]
def register_compute_node_routes(app, *, require_user, require_admin) -> None: def register_software_node_routes(app, *, require_user, require_admin) -> None:
@app.post( @app.post(
"/v1/compute/nodes/enroll", "/v1/software-nodes/enroll",
tags=["compute-nodes"], tags=["software-nodes"],
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
) )
def node_enroll(body: ComputeNodeEnrollRequest): def node_enroll(body: SoftwareNodeEnrollRequest):
try: try:
return enroll_node(**body.model_dump()) return enroll_node(**body.model_dump())
except ComputeNodeError as exc: except SoftwareNodeError as exc:
raise HTTPException(400, str(exc)) from exc raise HTTPException(400, str(exc)) from exc
@app.get("/v1/compute/jobs/{job_id}/input", tags=["compute-nodes"]) @app.get("/v1/software-jobs/{job_id}/input", tags=["software-nodes"])
def download_compute_job_input( def download_software_job_input(
job_id: UUID, job_id: UUID,
authorization: str | None = Header(default=None), authorization: str | None = Header(default=None),
x_node_id: str = Header(default=""), x_node_id: str = Header(default=""),
@ -219,23 +220,23 @@ def register_compute_node_routes(app, *, require_user, require_admin) -> None:
try: try:
node_id = UUID(x_node_id) node_id = UUID(x_node_id)
authenticate_node(node_id, _bearer(authorization)) authenticate_node(node_id, _bearer(authorization))
except (ValueError, ComputeNodeError) as exc: except (ValueError, SoftwareNodeError) as exc:
raise HTTPException(401, "invalid node credentials") from exc raise HTTPException(401, "invalid node credentials") from exc
item = get_job_input(node_id, job_id) item = get_job_input(node_id, job_id)
if item is None: if item is None:
raise HTTPException(404, "compute job input not found") raise HTTPException(404, "software job input not found")
target = safe_join(load_user_root(item["user_id"]), item["current_path"]) target = safe_join(load_user_root(item["user_id"]), item["current_path"])
if not target.is_file(): if not target.is_file():
raise HTTPException(404, "compute job input file not found") raise HTTPException(404, "software job input file not found")
stat = target.stat() stat = target.stat()
if stat.st_size != item["size_bytes"]: if stat.st_size != item["size_bytes"]:
raise HTTPException(409, "compute job input changed after submission") raise HTTPException(409, "software job input changed after submission")
digest = sha256() digest = sha256()
with target.open("rb") as handle: with target.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""): for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk) digest.update(chunk)
if digest.hexdigest() != item["sha256"]: if digest.hexdigest() != item["sha256"]:
raise HTTPException(409, "compute job input changed after submission") raise HTTPException(409, "software job input changed after submission")
return FileResponse( return FileResponse(
path=str(target), path=str(target),
filename=item["filename"], filename=item["filename"],
@ -247,11 +248,11 @@ def register_compute_node_routes(app, *, require_user, require_admin) -> None:
) )
@app.put( @app.put(
"/v1/compute/jobs/{job_id}/outputs/{artifact_id}", "/v1/software-jobs/{job_id}/outputs/{artifact_id}",
tags=["compute-nodes"], tags=["software-nodes"],
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
) )
async def upload_compute_job_output( async def upload_software_job_output(
job_id: UUID, job_id: UUID,
artifact_id: str, artifact_id: str,
request: Request, request: Request,
@ -286,7 +287,7 @@ def register_compute_node_routes(app, *, require_user, require_admin) -> None:
if published.stat().st_size == x_content_length and _hash_file(published) == x_content_sha256: if published.stat().st_size == x_content_length and _hash_file(published) == x_content_sha256:
return None return None
raise HTTPException(409, "published output conflicts with uploaded artifact") raise HTTPException(409, "published output conflicts with uploaded artifact")
staging = safe_join(root, f".zcbot_compute_staging/{job_id}") staging = safe_join(root, f".zcbot_software_job_staging/{job_id}")
_reject_symlink_path(root, staging) _reject_symlink_path(root, staging)
staging.mkdir(parents=True, exist_ok=True) staging.mkdir(parents=True, exist_ok=True)
destination = staging / filename destination = staging / filename
@ -298,7 +299,7 @@ def register_compute_node_routes(app, *, require_user, require_admin) -> None:
item.stat().st_size for item in staging.iterdir() if item.is_file() item.stat().st_size for item in staging.iterdir() if item.is_file()
) )
if staged_total + x_content_length > MAX_OUTPUT_TOTAL_BYTES: if staged_total + x_content_length > MAX_OUTPUT_TOTAL_BYTES:
raise HTTPException(413, "compute job outputs exceed the total size limit") raise HTTPException(413, "software job outputs exceed the total size limit")
temporary = destination.with_name(destination.name + ".tmp-" + os.urandom(8).hex()) temporary = destination.with_name(destination.name + ".tmp-" + os.urandom(8).hex())
digest = sha256() digest = sha256()
total = 0 total = 0
@ -319,8 +320,8 @@ def register_compute_node_routes(app, *, require_user, require_admin) -> None:
temporary.unlink(missing_ok=True) temporary.unlink(missing_ok=True)
return None return None
@app.post("/v1/compute/jobs/{job_id}/outputs/complete", tags=["compute-nodes"]) @app.post("/v1/software-jobs/{job_id}/outputs/complete", tags=["software-nodes"])
async def complete_compute_job_outputs( async def complete_software_job_outputs(
job_id: UUID, job_id: UUID,
request: Request, request: Request,
authorization: str | None = Header(default=None), authorization: str | None = Header(default=None),
@ -337,7 +338,9 @@ def register_compute_node_routes(app, *, require_user, require_admin) -> None:
raise HTTPException(400, "output completion body must be an object") raise HTTPException(400, "output completion body must be an object")
try: try:
manifest = validate_output_manifest(context["request"], body.get("artifact_manifest")) manifest = validate_output_manifest(context["request"], body.get("artifact_manifest"))
published = await asyncio.to_thread(_publish_compute_outputs, job_id, context, manifest) published = await asyncio.to_thread(
_publish_software_job_outputs, job_id, context, manifest
)
terminal = { terminal = {
"job_id": str(job_id), "job_id": str(job_id),
"lease_id": str(lease_id), "lease_id": str(lease_id),
@ -347,17 +350,17 @@ def register_compute_node_routes(app, *, require_user, require_admin) -> None:
"artifact_manifest": published, "artifact_manifest": published,
} }
await asyncio.to_thread(record_job_terminal, node_id, terminal) await asyncio.to_thread(record_job_terminal, node_id, terminal)
except (ComputeNodeError, KeyError, TypeError) as exc: except (SoftwareJobError, KeyError, TypeError) as exc:
raise HTTPException(409, str(exc)) from exc raise HTTPException(409, str(exc)) from exc
return {"status": "succeeded", "artifact_manifest": published} return {"status": "succeeded", "artifact_manifest": published}
@app.websocket("/v1/compute/nodes/connect") @app.websocket("/v1/software-nodes/connect")
async def node_connect(websocket: WebSocket): async def node_connect(websocket: WebSocket):
try: try:
node_id = UUID(websocket.headers.get("x-node-id", "")) node_id = UUID(websocket.headers.get("x-node-id", ""))
token = _bearer(websocket.headers.get("authorization")) token = _bearer(websocket.headers.get("authorization"))
identity = await asyncio.to_thread(authenticate_node, node_id, token) identity = await asyncio.to_thread(authenticate_node, node_id, token)
except (ValueError, ComputeNodeError): except (ValueError, SoftwareNodeError):
# 握手前 close 会被 ASGI 统一表现为 HTTP 403客户端无法区分 # 握手前 close 会被 ASGI 统一表现为 HTTP 403客户端无法区分
# “凭据无效”和“代理/路由没有正确转发 WebSocket”。先升级再用 # “凭据无效”和“代理/路由没有正确转发 WebSocket”。先升级再用
# 应用关闭码给已持有 Node ID/Token 的节点返回明确诊断。 # 应用关闭码给已持有 Node ID/Token 的节点返回明确诊断。
@ -443,17 +446,17 @@ def register_compute_node_routes(app, *, require_user, require_admin) -> None:
await asyncio.to_thread( await asyncio.to_thread(
abandon_offer, offer["node_id"], offer["payload"] abandon_offer, offer["node_id"], offer["payload"]
) )
except (ComputeNodeError, WebSocketDisconnect, RuntimeError, ValueError): except (SoftwareNodeError, WebSocketDisconnect, RuntimeError, ValueError):
pass pass
finally: finally:
if await node_connections.remove(node_id, websocket): if await node_connections.remove(node_id, websocket):
await asyncio.to_thread(mark_node_offline, node_id) await asyncio.to_thread(mark_node_offline, node_id)
await asyncio.to_thread(mark_node_jobs_disconnected, node_id) await asyncio.to_thread(mark_node_jobs_disconnected, node_id)
@app.post("/v1/tasks/{task_id}/compute-jobs", tags=["compute-jobs"]) @app.post("/v1/tasks/{task_id}/software-jobs", tags=["software-jobs"])
async def submit_compute_job( async def submit_software_job(
task_id: UUID, task_id: UUID,
body: ComputeJobCreateRequest, body: SoftwareJobCreateRequest,
user_id: UUID = Depends(require_user), # noqa: B008 user_id: UUID = Depends(require_user), # noqa: B008
): ):
try: try:
@ -472,42 +475,42 @@ def register_compute_node_routes(app, *, require_user, require_admin) -> None:
abandon_offer, offer["node_id"], offer["payload"] abandon_offer, offer["node_id"], offer["payload"]
) )
return {**job, "created": created} return {**job, "created": created}
except ComputeNodeError as exc: except SoftwareJobError as exc:
detail = str(exc) detail = str(exc)
raise HTTPException(404 if detail == "task not found" else 400, detail) from exc raise HTTPException(404 if detail == "task not found" else 400, detail) from exc
@app.get("/v1/compute-jobs/{job_id}", tags=["compute-jobs"]) @app.get("/v1/software-jobs/{job_id}", tags=["software-jobs"])
def read_compute_job( def read_software_job(
job_id: UUID, job_id: UUID,
user_id: UUID = Depends(require_user), # noqa: B008 user_id: UUID = Depends(require_user), # noqa: B008
): ):
job = get_job(user_id, job_id) job = get_job(user_id, job_id)
if job is None: if job is None:
raise HTTPException(404, "compute job not found") raise HTTPException(404, "software job not found")
return job return job
@app.post("/v1/admin/compute-node-enrollments", tags=["admin"]) @app.post("/v1/admin/software-node-enrollments", tags=["admin"])
def admin_create_compute_enrollment( def admin_create_software_enrollment(
body: ComputeEnrollmentCreateRequest, body: SoftwareEnrollmentCreateRequest,
user_id: UUID = Depends(require_admin), # noqa: B008 user_id: UUID = Depends(require_admin), # noqa: B008
): ):
try: try:
return create_enrollment(user_id, **body.model_dump()) return create_enrollment(user_id, **body.model_dump())
except ComputeNodeError as exc: except SoftwareNodeError as exc:
raise HTTPException(400, str(exc)) from exc raise HTTPException(400, str(exc)) from exc
@app.get("/v1/admin/compute-nodes", tags=["admin"]) @app.get("/v1/admin/software-nodes", tags=["admin"])
def admin_compute_nodes(user_id: UUID = Depends(require_admin)): # noqa: B008 def admin_software_nodes(user_id: UUID = Depends(require_admin)): # noqa: B008
return {"results": list_nodes()} return {"results": list_nodes()}
@app.patch("/v1/admin/compute-nodes/{node_id}", tags=["admin"]) @app.patch("/v1/admin/software-nodes/{node_id}", tags=["admin"])
async def admin_disable_compute_node( async def admin_disable_software_node(
node_id: UUID, node_id: UUID,
body: ComputeNodeDisableRequest, body: SoftwareNodeDisableRequest,
user_id: UUID = Depends(require_admin), # noqa: B008 user_id: UUID = Depends(require_admin), # noqa: B008
): ):
if not await asyncio.to_thread(set_node_disabled, node_id, body.disabled): if not await asyncio.to_thread(set_node_disabled, node_id, body.disabled):
raise HTTPException(404, "compute node not found") raise HTTPException(404, "software node not found")
if body.disabled: if body.disabled:
await node_connections.close(node_id) await node_connections.close(node_id)
return { return {
@ -515,13 +518,13 @@ def register_compute_node_routes(app, *, require_user, require_admin) -> None:
"status": "disabled" if body.disabled else "offline", "status": "disabled" if body.disabled else "offline",
} }
@app.delete("/v1/admin/compute-nodes/{node_id}", tags=["admin"]) @app.delete("/v1/admin/software-nodes/{node_id}", tags=["admin"])
async def admin_delete_compute_node( async def admin_delete_software_node(
node_id: UUID, node_id: UUID,
user_id: UUID = Depends(require_admin), # noqa: B008 user_id: UUID = Depends(require_admin), # noqa: B008
): ):
# 先撤掉在线连接,避免删除后的旧 socket 继续上报运行态。 # 先撤掉在线连接,避免删除后的旧 socket 继续上报运行态。
await node_connections.close(node_id) await node_connections.close(node_id)
if not await asyncio.to_thread(delete_node, node_id): if not await asyncio.to_thread(delete_node, node_id):
raise HTTPException(404, "compute node not found") raise HTTPException(404, "software node not found")
return {"node_id": str(node_id), "status": "deleted"} return {"node_id": str(node_id), "status": "deleted"}

View File

@ -116,13 +116,13 @@ class ExternalSystemCredentialsRequest(BaseModel):
credentials: dict[str, str] = Field(default_factory=dict) credentials: dict[str, str] = Field(default_factory=dict)
class ComputeEnrollmentCreateRequest(BaseModel): class SoftwareEnrollmentCreateRequest(BaseModel):
expected_name: str = "" expected_name: str = ""
capabilities: list[str] = Field(default_factory=lambda: ["origin.plot@v1"]) capabilities: list[str] = Field(default_factory=lambda: ["origin.plot@v1"])
ttl_seconds: int = 600 ttl_seconds: int = 600
class ComputeNodeEnrollRequest(BaseModel): class SoftwareNodeEnrollRequest(BaseModel):
enrollment_code: str enrollment_code: str
node_name: str node_name: str
install_id: UUID install_id: UUID
@ -131,11 +131,11 @@ class ComputeNodeEnrollRequest(BaseModel):
capabilities: list[str] capabilities: list[str]
class ComputeNodeDisableRequest(BaseModel): class SoftwareNodeDisableRequest(BaseModel):
disabled: bool = True disabled: bool = True
class ComputeJobCreateRequest(BaseModel): class SoftwareJobCreateRequest(BaseModel):
idempotency_key: str idempotency_key: str
capability: str = "origin.plot@v1" capability: str = "origin.plot@v1"
request: dict = Field(default_factory=dict) request: dict = Field(default_factory=dict)

View File

@ -55,7 +55,7 @@ let externalDefinitions = [];
let externalUsers = []; let externalUsers = [];
let externalDefinitionsLoaded = false; let externalDefinitionsLoaded = false;
let externalEditingId = ""; let externalEditingId = "";
let computeNodes = []; let softwareNodes = [];
// ───── 格式化 ───── // ───── 格式化 ─────
function fmtCNY(n) { function fmtCNY(n) {
@ -167,7 +167,7 @@ function nodeStatusHTML(status) {
} }
function renderWindowsNodes() { function renderWindowsNodes() {
const rows = computeNodes.map(node => { const rows = softwareNodes.map(node => {
const runtime = node.runtime || {}; const runtime = node.runtime || {};
const origin = runtime.origin || {}; const origin = runtime.origin || {};
const originState = origin.health === "ready" ? "Origin 可用" : "Origin 不可用"; const originState = origin.health === "ready" ? "Origin 可用" : "Origin 不可用";
@ -197,22 +197,22 @@ function renderWindowsNodes() {
}).join("") || `<tr><td colspan="7" class="empty">尚无已注册的 Windows Node</td></tr>`; }).join("") || `<tr><td colspan="7" class="empty">尚无已注册的 Windows Node</td></tr>`;
$("s-windows-node").innerHTML = `<div class="card"><div class="card-head">` $("s-windows-node").innerHTML = `<div class="card"><div class="card-head">`
+ `<div><h2>Windows Node${computeNodes.length}</h2><div class="node-help">查看节点状态;禁用会立即断开节点并拒绝后续连接。</div></div>` + `<div><h2>Windows Node${softwareNodes.length}</h2><div class="node-help">查看节点状态;禁用会立即断开节点并拒绝后续连接。</div></div>`
+ `<button id="node-enrollment-open" class="primary" type="button">生成 Windows Node 注册码</button>` + `<button id="node-enrollment-open" class="primary" type="button">生成 Windows Node 注册码</button>`
+ `</div><div class="scroll-x"><table><thead><tr><th>节点</th><th>状态</th><th>运行环境</th>` + `</div><div class="scroll-x"><table><thead><tr><th>节点</th><th>状态</th><th>运行环境</th>`
+ `<th>版本</th><th>能力</th><th>最近心跳</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table></div></div>`; + `<th>版本</th><th>能力</th><th>最近心跳</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table></div></div>`;
$("node-enrollment-open").onclick = openNodeEnrollmentModal; $("node-enrollment-open").onclick = openNodeEnrollmentModal;
$("s-windows-node").querySelectorAll("[data-node-toggle]").forEach(button => { $("s-windows-node").querySelectorAll("[data-node-toggle]").forEach(button => {
button.onclick = () => toggleComputeNode(button); button.onclick = () => toggleSoftwareNode(button);
}); });
$("s-windows-node").querySelectorAll("[data-node-delete]").forEach(button => { $("s-windows-node").querySelectorAll("[data-node-delete]").forEach(button => {
button.onclick = () => deleteComputeNode(button); button.onclick = () => deleteSoftwareNode(button);
}); });
} }
async function toggleComputeNode(button) { async function toggleSoftwareNode(button) {
const row = button.closest("tr[data-node-id]"); const row = button.closest("tr[data-node-id]");
const node = computeNodes.find(item => item.node_id === row?.dataset.nodeId); const node = softwareNodes.find(item => item.node_id === row?.dataset.nodeId);
if (!node) return; if (!node) return;
const disabling = button.dataset.nodeToggle === "disable"; const disabling = button.dataset.nodeToggle === "disable";
const confirmed = await dialogConfirm({ const confirmed = await dialogConfirm({
@ -226,20 +226,20 @@ async function toggleComputeNode(button) {
if (!confirmed) return; if (!confirmed) return;
button.disabled = true; button.disabled = true;
try { try {
await apiSend("PATCH", `/v1/admin/compute-nodes/${node.node_id}`, { await apiSend("PATCH", `/v1/admin/software-nodes/${node.node_id}`, {
disabled: disabling, disabled: disabling,
}); });
message(disabling ? "节点已禁用" : "节点已重新启用,请在节点电脑上立即重连", "success", 5000); message(disabling ? "节点已禁用" : "节点已重新启用,请在节点电脑上立即重连", "success", 5000);
await loadComputeNodes(); await loadSoftwareNodes();
} catch (err) { } catch (err) {
if (err.code !== "auth") message("更新节点失败:" + (err.message || String(err)), "error", 5000); if (err.code !== "auth") message("更新节点失败:" + (err.message || String(err)), "error", 5000);
button.disabled = false; button.disabled = false;
} }
} }
async function deleteComputeNode(button) { async function deleteSoftwareNode(button) {
const row = button.closest("tr[data-node-id]"); const row = button.closest("tr[data-node-id]");
const node = computeNodes.find(item => item.node_id === row?.dataset.nodeId); const node = softwareNodes.find(item => item.node_id === row?.dataset.nodeId);
if (!node) return; if (!node) return;
const confirmed = await dialogConfirm({ const confirmed = await dialogConfirm({
title: "删除 Windows Node", title: "删除 Windows Node",
@ -250,9 +250,9 @@ async function deleteComputeNode(button) {
if (!confirmed) return; if (!confirmed) return;
button.disabled = true; button.disabled = true;
try { try {
await apiSend("DELETE", `/v1/admin/compute-nodes/${node.node_id}`, {}); await apiSend("DELETE", `/v1/admin/software-nodes/${node.node_id}`, {});
message("节点已删除,本机需重新注册后才能使用", "success", 5000); message("节点已删除,本机需重新注册后才能使用", "success", 5000);
await loadComputeNodes(); await loadSoftwareNodes();
} catch (err) { } catch (err) {
if (err.code !== "auth") message("删除节点失败:" + (err.message || String(err)), "error", 5000); if (err.code !== "auth") message("删除节点失败:" + (err.message || String(err)), "error", 5000);
button.disabled = false; button.disabled = false;
@ -310,7 +310,7 @@ async function createNodeEnrollment(e) {
submit.disabled = true; submit.disabled = true;
submit.textContent = "生成中…"; submit.textContent = "生成中…";
try { try {
const result = await apiSend("POST", "/v1/admin/compute-node-enrollments", { const result = await apiSend("POST", "/v1/admin/software-node-enrollments", {
expected_name: $("node-expected-name").value.trim(), expected_name: $("node-expected-name").value.trim(),
capabilities: ["origin.plot@v1"], capabilities: ["origin.plot@v1"],
ttl_seconds: 600, ttl_seconds: 600,
@ -1035,10 +1035,10 @@ async function loadExternalDefinitions(force = false) {
} catch (e) { /* overview 统一处理鉴权 */ } } catch (e) { /* overview 统一处理鉴权 */ }
} }
async function loadComputeNodes() { async function loadSoftwareNodes() {
try { try {
const result = await apiGet("/v1/admin/compute-nodes"); const result = await apiGet("/v1/admin/software-nodes");
computeNodes = result.results || []; softwareNodes = result.results || [];
renderWindowsNodes(); renderWindowsNodes();
} catch (e) { /* overview 统一处理鉴权 */ } } catch (e) { /* overview 统一处理鉴权 */ }
} }
@ -1052,7 +1052,7 @@ async function refresh() {
loadModels(); loadModels();
loadUserUsage(userPage); loadUserUsage(userPage);
loadStorage(storagePage); loadStorage(storagePage);
loadComputeNodes(); loadSoftwareNodes();
loadExternalDefinitions(); loadExternalDefinitions();
loadToolFailures(); loadToolFailures();
} catch (e) { } catch (e) {

View File

@ -52,7 +52,7 @@ internal static class NodeUri
internal static Uri WebSocketEndpoint(Uri serverUrl) internal static Uri WebSocketEndpoint(Uri serverUrl)
{ {
var builder = new UriBuilder(new Uri(serverUrl, "v1/compute/nodes/connect")) var builder = new UriBuilder(new Uri(serverUrl, "v1/software-nodes/connect"))
{ {
Scheme = serverUrl.Scheme == Uri.UriSchemeHttps ? "wss" : "ws" Scheme = serverUrl.Scheme == Uri.UriSchemeHttps ? "wss" : "ws"
}; };

View File

@ -28,7 +28,7 @@ internal static class EnrollmentClient
using var client = new HttpClient { BaseAddress = options.ServerUrl, Timeout = TimeSpan.FromSeconds(30) }; using var client = new HttpClient { BaseAddress = options.ServerUrl, Timeout = TimeSpan.FromSeconds(30) };
using var response = await client.PostAsJsonAsync( using var response = await client.PostAsJsonAsync(
"v1/compute/nodes/enroll", request, cancellationToken); "v1/software-nodes/enroll", request, cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
var detail = await response.Content.ReadAsStringAsync(cancellationToken); var detail = await response.Content.ReadAsStringAsync(cancellationToken);

View File

@ -277,7 +277,7 @@ internal sealed class JobInboxStore(string jobsDirectory)
&& transfer.TryGetProperty("sha256", out var sha) && transfer.TryGetProperty("sha256", out var sha)
&& sha.GetString() is { Length: 64 } && sha.GetString() is { Length: 64 }
&& transfer.TryGetProperty("download_path", out var downloadPath) && transfer.TryGetProperty("download_path", out var downloadPath)
&& downloadPath.GetString()?.StartsWith("/v1/compute/jobs/", StringComparison.Ordinal) == true && downloadPath.GetString()?.StartsWith("/v1/software-jobs/", StringComparison.Ordinal) == true
&& downloadPath.GetString()?.EndsWith("/input", StringComparison.Ordinal) == true; && downloadPath.GetString()?.EndsWith("/input", StringComparison.Ordinal) == true;
internal string InputPath(RecoverableJob job) internal string InputPath(RecoverableJob job)

View File

@ -13,7 +13,7 @@ internal sealed class JobInputDownloader(NodeConfig config, JobInboxStore inbox)
throw new InvalidDataException("Job input transfer is missing."); throw new InvalidDataException("Job input transfer is missing.");
} }
var downloadPath = transfer.GetProperty("download_path").GetString()!; var downloadPath = transfer.GetProperty("download_path").GetString()!;
if (!downloadPath.StartsWith("/v1/compute/jobs/", StringComparison.Ordinal) if (!downloadPath.StartsWith("/v1/software-jobs/", StringComparison.Ordinal)
|| !downloadPath.EndsWith("/input", StringComparison.Ordinal) || !downloadPath.EndsWith("/input", StringComparison.Ordinal)
|| !Uri.TryCreate(downloadPath, UriKind.Relative, out var relativeUri)) || !Uri.TryCreate(downloadPath, UriKind.Relative, out var relativeUri))
{ {

View File

@ -58,7 +58,7 @@ internal sealed class JobOutputUploader(NodeConfig config)
content.Headers.Add("X-Content-SHA256", expectedDigest); content.Headers.Add("X-Content-SHA256", expectedDigest);
content.Headers.Add("X-Content-Length", expectedSize.ToString()); content.Headers.Add("X-Content-Length", expectedSize.ToString());
using var response = await client.PutAsync( using var response = await client.PutAsync(
$"/v1/compute/jobs/{job.JobId:D}/outputs/{Uri.EscapeDataString(localId)}", $"/v1/software-jobs/{job.JobId:D}/outputs/{Uri.EscapeDataString(localId)}",
content); content);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
@ -68,7 +68,7 @@ internal sealed class JobOutputUploader(NodeConfig config)
Encoding.UTF8, Encoding.UTF8,
"application/json"); "application/json");
using var completeResponse = await client.PostAsync( using var completeResponse = await client.PostAsync(
$"/v1/compute/jobs/{job.JobId:D}/outputs/complete", completeContent); $"/v1/software-jobs/{job.JobId:D}/outputs/complete", completeContent);
completeResponse.EnsureSuccessStatusCode(); completeResponse.EnsureSuccessStatusCode();
var responseBody = await completeResponse.Content.ReadAsByteArrayAsync(); var responseBody = await completeResponse.Content.ReadAsByteArrayAsync();
AtomicWrite(completionPath, responseBody); AtomicWrite(completionPath, responseBody);