feat(node): persist professional software workspaces

This commit is contained in:
caoqianming 2026-08-19 10:55:19 +08:00
parent 7708828c72
commit e521f832c4
32 changed files with 1666 additions and 81 deletions

View File

@ -8,6 +8,8 @@
## Unreleased
- Origin 专业软件任务现在会在原 Windows Node 上保留可继续加工的工程;后续调整直接打开上一版工程,过程只提供轻量预览,工程和其他大文件仅在明确要求下载或交付时才传回并登记为正式产物。
- 右侧工作区改为“文件 / 软件作业”双面板,标准页面和 embed 模式保持一致,折叠后仍可查看作业数量与完成提示;文件面板新增“当前对话 / 全部文件”切换、当前目录搜索、排序和修改时间展示。
- 专业软件任务提交后由系统统一发送完成通知和产物Agent 不再自行等待并重复报告Origin PNG 改由清理异常文字基线后的矢量页渲染,三维图在指定毫米画布时也会同步缩放模板图层,避免横线及坐标标题、色标和文字裁切。

View File

@ -476,11 +476,15 @@ Node 通过 `Authorization: Bearer` 与 `X-Node-Id` 建立 `/v1/software-nodes/c
Recipe 是专业软件的声明式目标状态,不是任意脚本或逐次鼠标命令。`origin.plot@v2` 以新增 `plot.type=recipe + recipe_version=1` 复用已验证的 `layout/panels/series/axis/legend` 组合结构;旧 `multi_panel` 与 Recipe 进入同一个受控执行器,单图和特殊统计图继续保留兼容入口。模型可以组合契约允许的原子图形能力,但不能提供 Python、LabTalk、解释器、模板名或文件路径。其他软件可采用同一通用 Job 外壳和各自的声明式 Recipe不建设跨软件万能 DSLAPI/SDK executor 优先,未来的 UI Automation 或 Computer Use 仅作为 adapter 内部执行后端,不改变云端 Recipe。
产物修改第一阶段采用不可变重新生成:`software_job_status(job_id)` 对当前 user/task 返回规范化 `editable_request``software_job_revise(source_job_id, operation, outputs)` 在服务端复用源 Job 已登记的 inputs并重新经过当前 capability 契约、artifact 权限和调度校验创建新 Job。它不打开旧 OPJU、不覆盖旧产物、不接受模型重新指定输入也暂不增加版本树 migration若以后出现必须保留用户在 GUI 内手工编辑的真实需求,再单独设计工程副本、稳定对象 ID 和增量执行。
专业软件的连续加工以独立 `software_workspaces` 为稳定边界Job 只记录一次操作。首个 Job 创建独立 `workspace_id`,两者不共用 ID服务端保存 user 归属、capability、`home_node_id`、`head_job_id`、容量与留存元数据Node 物理目录只使用 `%ProgramData%/Zcbot/WindowsNode/workspaces/<workspace_id>/`,不再按 user 建上层目录。这样用户统计集中在数据库完成,节点只管理实际落到本机的 workspace也避免多节点各自维护一套用户目录树。Workspace 一旦首次调度即粘在 home node后续 Job 必须以当前 head 为 source、串行回到同一节点节点离线时等待而不复制工程或静默改派。
Node 对每个 Workspace 只保留 `current``rollback` 两代。续作开始前把 `current` 原子切换为 `rollback`Worker 打开该工程并将新状态写入 Job 输出区;成功后输出区提升为新 `current`,失败或进程中断则恢复 `rollback`。因此连续加工不会为每个 Job 永久复制一份工程物理空间上限约为当前工程加一份回滚工程Job 账本仍完整保留参数、摘要和 local manifest。`software_job_revise(source_job_id, operation, outputs)` 只允许从 Workspace 当前 head 继续,复用已登记输入并重新经过当前契约校验。当前 Origin adapter 以 OPJU 为 workspace stateANSYS 静力 capability 仍是无状态一次性执行,契约中的 `workspace: null` 明确保持原发布流程。
科研统计图继续以独立 feature 增量扩展同一契约:`box/histogram` 的系列只绑定原始 Y 列,统计规则由 Origin 固定模板决定;`bubble` 增加正数 `size` 数据角色,由受控 modifier column 驱动符号尺寸;`band` 要求同一输入的 `x/y/lower/upper`先画上下界并填充到下一曲线再叠加中心线Worker 在打开 Origin 前拒绝非有限尺寸、非正尺寸和倒置边界。`stacked_column/stacked_area/stacked_bar` 统一把两条以上 XY 系列复制进内部连续 XYY 工作表,严格校验横坐标相同后建立 plot group并只执行 Worker 内置的固定累计图层命令;请求不能提供命令、模板或工作表范围。`area/polar/pie` 继续使用固定 Origin 类型 ID。未经过目标 Origin 版本真机验证的统计属性不进入公共 schema避免暴露看似可配但不能稳定复现的参数。
第五阶段完成输出上传与发布Node 只按固定 manifest ID 逐项流式 PUT并携带 Node、lease、request digest 与内容摘要;云端重新绑定任务身份,不信 Node 提供的路径或媒体类型。文件先进入用户根下隐藏暂存区,固定文件名、单文件/总大小和 SHA-256 全部验证后,把 plot spec、provenance 整理进 `.meta/`,再将完整目录原子移动到 `<working_dir>/origin/<job_id>/`。PNG/SVG/PDF/OPJU 等正式输出登记平台 artifact UUID 和 `software_job_id``.meta/` 只落真实文件;成功状态返回 task-relative `output_dir`Agent 以该目录为起点按需搜索。重复 PUT、complete 和重连均按摘要幂等部分上传不可见只有完整集合才能发布。Origin 执行槽与上传确认是两个正交状态:本地已有终态且固定 Worker 已退出时即释放软件执行槽,成功但尚无 `upload-complete.json` 的任务继续后台补传;若云端已经是 succeeded重复 PUT/complete 必须按数据库持久化 manifest 校验并直接确认,不得按新版本目录规则重新发布旧 Job。
Workspace capability 的默认完成协议不发布正式产物。Node 只自动上传契约声明的轻量 `preview_outputs`,云端校验后放入 `.zcbot_cache/software-previews/` 并记录 `preview_manifest`;预览可在对话中展示,但没有 artifact UUID不进入正式文件列表也不作为后续加工的事实源。完整工程和其他重输出保留在 home node 的 `current` 中,`local_manifest` 只记录固定 output ID、摘要、大小和媒体类型。只有用户明确要求下载、交付或导出时Agent 调用 `software_job_export`,服务端向 home node 下发所选 output IDNode 才流式上传,云端复核后移入 task 输出目录并登记正式 Artifact。预览缓存与 Artifact 因此是两条独立生命周期,避免中间截图、低清视频和多轮工程文件污染用户产物。
无状态 capability 继续使用原完成即发布协议Node 按固定 manifest ID 逐项流式 PUT云端重新绑定路径与媒体类型校验完整集合后原子发布。两种协议都以 Node、lease、request digest、大小和 SHA-256 做幂等校验Origin 执行槽在本地终态和 Workspace 提升完成后释放,预览或显式导出的传输失败可在重连时继续,不重复驱动专业软件。
第六阶段增加用户级 Job 中心与 Agent typed tools。`software_capability_list` 只暴露固定能力及当前在线空闲节点数,`software_job_submit/status/cancel` 在构造时绑定当前 user/task模型不能跨用户或跨对话指定归属。`software_job_submit` 的唯一入口为通用 `inputs[] + operation + outputs[]`;输入和输出使用任务内稳定 key具体 selector、type、format 和 options 由 capability 校验。`register_artifact` 是普通文件获得输入身份的唯一入口,并明确返回 UUID。右下角 Job 中心按用户聚合各对话任务,活动期短轮询、空闲期降频;终态变化通知用户,成功任务可回到原对话发起分析。取消采用协作协议:未派发任务直接终止,已派发任务先进入 `cancelling`,云端通过 WebSocket 发送并在心跳时重放 `job_cancel`Node 杀死固定 Worker 进程树后回报 `cancelled`;终态写入仍由云端账本裁决。

View File

@ -20,6 +20,8 @@
---
## 已完成关键能力
- **08-19 / Unreleased / Windows Node 持久 Workspace 与按需导出**:新增独立 `software_workspaces` 注册表,首个 Job 建立 Workspace、后续 Job 只从当前 head 在同一 home node 串行续作Node 以 `workspaces/<workspace_id>/current + rollback` 保存两代状态Origin adapter 1.0.0 可打开上一版 OPJU 再加工,失败自动回滚且不会按 Job 永久复制工程。Workspace 完成时只上传契约声明的预览缓存,预览不注册 Artifact用户明确要求下载或交付时才由 `software_job_export` 触发原节点上传并登记正式产物。专项 101 项 unittest 与 .NET build 通过;全量 672 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项),未连接或迁移生产数据库。
- **08-18 / Unreleased / Windows Node 能力自动同步**:注册码只建立节点身份,不再要求管理员选择 Origin/ANSYS 能力Node 从 adapter registry 自动发现全部本机能力,并在首次连接及每次心跳上报,服务端以双方已有共享契约的能力覆盖节点调度列表,管理后台改为只读展示。现有节点无需清身份或重新注册,替换新版 Node 并重启一次即可同步。
- **08-18 / Unreleased / Windows Node 多 runtime 安装修复**:修复统一 BAT 安装器在连续处理 Origin 与 ANSYS 时依赖跨子程序 `PYTHON_EXE/PYTHON_ARGS` 状态、第二个 venv 可能执行成空命令的问题;每个缺失 runtime 现在都直接用已验证的显式 Python 3.12 命令创建,不再共享可变命令状态。

10
RUN.md
View File

@ -1054,7 +1054,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
### Windows Node 内网 MVP开发中
先执行 `.venv/Scripts/python.exe main.py db upgrade head` 创建 `software_node_enrollments`、`software_nodes`、`software_jobs`,为 artifact 增加专业软件来源字段,并加入 Job 完成策略与续跑状态。不要在未确认目标数据库时运行迁移;本机 `.env``ZCBOT_DB_URL` 可能是生产隧道。
先执行 `.venv/Scripts/python.exe main.py db upgrade head` 创建或升级 `software_node_enrollments`、`software_nodes`、`software_jobs``software_workspaces`,为 artifact 增加专业软件来源字段,并加入 Job 完成策略、Workspace head、本机 manifest、预览和按需导出状态。不要在未确认目标数据库时运行迁移;本机 `.env``ZCBOT_DB_URL` 可能是生产隧道。
0032 旧版曾把 `tasks.working_dir` 二次拼进 user root已完成 Job 的文件和 artifact 路径可能位于重复的 `workspace/users/<uid>/...` 子树。升级到 0033 后,用显式迁移库地址先 dry-run确认所有目标目录无冲突后再加 `--apply`。脚本不加载 `.env`,文件和数据库更新均按 Job manifest 精确处理,目标目录同时存在时会停止,不能覆盖。
@ -1098,7 +1098,9 @@ cd /d D:\ZcbotNode
install-windows-node.bat
```
若 Python 未加入 PATH可把绝对路径作为第一个参数例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认解释器分别为 `%ProgramData%\Zcbot\WindowsNode\runtimes\origin\Scripts\python.exe``%ProgramData%\Zcbot\WindowsNode\runtimes\ansys\Scripts\python.exe`。如需使用其他受管解释器,使用机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON``ZCBOT_ADAPTER_ANSYS_PYTHON`;旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务和 runtime 集中保存在 `%ProgramData%\Zcbot\WindowsNode\`,不会因替换程序目录而丢失。运行时固定依赖见各 adapter 的 `requirements.txt`任务请求无权选择解释器、脚本或路径。Origin Worker 支持 CSV/XLSX/JSON 输入及 OPJU/PNG/SVG/PDF 输出,成功产物发布到 `origin/<job_id>/`plot spec 与 provenance 位于其 `.meta/`;上传中断会在重连时幂等续传。
若 Python 未加入 PATH可把绝对路径作为第一个参数例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认解释器分别为 `%ProgramData%\Zcbot\WindowsNode\runtimes\origin\Scripts\python.exe``%ProgramData%\Zcbot\WindowsNode\runtimes\ansys\Scripts\python.exe`。如需使用其他受管解释器,使用机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON``ZCBOT_ADAPTER_ANSYS_PYTHON`;旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务、workspace 和 runtime 集中保存在 `%ProgramData%\Zcbot\WindowsNode\`,不会因替换程序目录而丢失。运行时固定依赖见各 adapter 的 `requirements.txt`;任务请求无权选择解释器、脚本或路径。
Workspace 目录固定为 `%ProgramData%\Zcbot\WindowsNode\workspaces\<workspace-id>\`,不使用 user ID 上层目录。`current` 是当前可续作工程,`rollback` 只保留上一版用于失败恢复;每次成功续作替换 `current`,不会按 Job 永久堆积工程副本。不要手工移动 workspace 到其他节点:服务端会把后续 Job 固定调度到其 home node节点离线时任务保持等待。备份或迁移节点时必须把数据库中的 workspace 归属和该目录作为一个整体处理。
ANSYS 能力为 `ansys.mechanical.static_structural@v1`,只面向 Mechanical 2024 R2revision 242。adapter 0.2.0 已实现 PyMechanical 0.11.3 真实静力流程Node 会从 `adapters/` 自动发现并在每次心跳上报全部能力,服务端管理页只读展示双方已有共享契约的能力,不需要管理员勾选,也不需要现有节点清身份或重新注册。默认 probe 仍返回不可用;在目标机器完成真实许可证与基准算例验收前,不得设置 `ZCBOT_ANSYS_242_VALIDATED=1`
@ -1125,11 +1127,11 @@ ANSYS 能力为 `ansys.mechanical.static_structural@v1`,只面向 Mechanical 2
重启后 probe 应报告 `health=ready`,再从服务端提交一个同规格小算例验证调度、上传与发布闭环。取消验收若提示 `CANCELLATION_JOB_FINISHED_BEFORE_CANCEL`,说明基准在设定秒数前已经完成,应减小 `--cancel-after` 后使用新的 `--work-root` 重跑,而不是把该次视为通过。
Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动任务或完成后的自动分析约 4 秒刷新一次,空闲时降为约 30 秒卡片优先显示状态、所属对话、输入输出、完成策略和耗时Job ID、输出目录、执行节点及软件版本收在“任务详情”中。成功任务可查看结果目录、复制完整 Job ID、深入分析或重新分析。停止已派发任务是协作取消状态先显示“正在停止”Node 在线时立即接收断线后在下次连接或心跳时重放。Agent 可调用 `software_capability_list`、`register_artifact`、`software_job_submit`、`software_job_status`、`software_job_revise` 和 `software_job_cancel`。Origin 输入必须是 artifact已有 UUID 可直接提交,普通 task 文件先逐个用相对路径登记;登记不会发布聊天交付卡片。提交工具接收 `inputs`、`operation`、`outputs` 和可选的 `completion_action=report|analyze`:只要求生成或保存时用默认 `report`,任务成功后平台向原对话写入固定产物报告;明确要求解释、总结或结论时用 `analyze`,平台在原 task 空闲后自动续跑 Agent。两者均不阻塞提交轮次。请求支持 116 个输入、跨输入系列和多个显式输出;`plot.canvas` 可指定毫米画布,轴可指定范围、步长、尺度、刻度角度/字号、标题字号和网格,`legend` 可控制显隐、位置和字号,`series[].style` 可控制颜色、线宽/线型、点型/点大小和透明度。所有排版字段可选,旧请求保持默认样式。成功状态提供 `output_dir`,正式输出的 artifact 带 `software_job_id`,供结果卡和产物详情展示来源。Node 输出上传的逐任务诊断日志位于 `%ProgramData%\Zcbot\WindowsNode\jobs\<job-id>\logs\node-output-upload.log`;日志包含上传阶段、产物文件名、重试次数和 Windows `HRESULT`,单文件达到 1 MiB 后轮转一份 `.1`,不记录 Node Token 或认证请求头。
Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动任务或完成后的自动分析约 4 秒刷新一次,空闲时降为约 30 秒卡片优先显示状态、所属对话、输入输出、完成策略和耗时Job ID、Workspace ID、执行节点及软件版本收在“任务详情”中。停止已派发任务是协作取消状态先显示“正在停止”Node 在线时立即接收断线后在下次连接或心跳时重放。Agent 可调用 `software_capability_list`、`register_artifact`、`software_job_submit`、`software_job_status`、`software_job_revise`、`software_job_export``software_job_cancel`。Origin 输入必须是 artifact已有 UUID 可直接提交,普通 task 文件先逐个用相对路径登记;登记不会发布聊天交付卡片。提交工具接收 `inputs`、`operation`、`outputs` 和可选的 `completion_action=report|analyze`。Workspace 任务成功后只自动上传契约声明的 PNG 等轻量预览,预览保存在隐藏缓存且不注册 ArtifactOPJU、SVG、PDF 等完整输出继续留在 Node。只有用户明确要求下载、发送或交付时才调用 `software_job_export(job_id, output_ids)`Node 会上传所选输出并在 task 目录登记正式 Artifact。Node 输出上传的逐任务诊断日志位于 `%ProgramData%\Zcbot\WindowsNode\jobs\<job-id>\logs\node-output-upload.log`;日志包含上传阶段、产物文件名、重试次数和 Windows `HRESULT`,单文件达到 1 MiB 后轮转一份 `.1`,不记录 Node Token 或认证请求头。
二维复合图使用 `plot.type=multi_panel`。`layout` 支持 1×1、1×2、2×1 和 2×2`panels` 数量为 14两个 panel 必须选择横排或竖排,三个和四个 panel 使用 2×2。每个 panel 的 `series[]` 必须显式给出 `kind``line/scatter/line_scatter/column/bar`),可用 `y_axis=right` 绑定右 Y 轴,并用 `x_error`、`y_error` 指定对称误差列;顶层 `x_axis/y_axis/legend` 作为全部 panel 的默认值panel 内同名设置可单独覆盖,`right_y_axis` 仍按 panel 设置。一个请求最多仍为 16 条系列,且每个 panel 至少有一条左轴系列。`share_x/share_y` 会统一各主图层自动缩放后的范围。复合图不接受 Origin 模板名也不支持把等高线、3D 曲面、三元图或热图混入 panel。
声明式绘图使用 `plot.type=recipe`、`recipe_version=1`,第一版有意复用上述 `layout/panels` 结构和限制,作为新的组合入口而不是开放脚本。对已有结果继续调整时,先用 `software_job_status(job_id)` 取得 `editable_request`,在其中形成完整的新 `operation``outputs`,再调用 `software_job_revise`;服务端自动复用源 Job 的 inputs重新校验并生成新 Job。旧 Job、OPJU 和图片不会被覆盖,当前也不会保留用户下载后在 Origin GUI 中做的手工修改
声明式绘图使用 `plot.type=recipe`、`recipe_version=1`,第一版有意复用上述 `layout/panels` 结构和限制,作为新的组合入口而不是开放脚本。对已有结果继续调整时,先用 `software_job_status(job_id)` 取得 `editable_request`,在其中形成完整的新 `operation``outputs`,再调用 `software_job_revise`;服务端自动复用源 Job 的 inputs并要求 source Job 是该 Workspace 当前 head。Origin Worker 打开 Node 上的上一版 OPJU将新工作表和图添加到工程后保存为新的 `current`;失败时恢复 `rollback`。Job 历史不被覆盖,但 Node 只保留当前和上一版工程文件
科研统计图使用独立单图 type。`box` 和 `histogram` 的系列填写 `input/y`,直接使用原始观测列并采用 Origin 默认箱线统计与自动分箱;`stacked_column` 至少提供两条同类 `input/x/y` 系列;`bubble` 使用 `input/x/y/size`,每行 size 必须是有限正数;`band` 使用 `input/x/y/lower/upper`,其中 y 是中心线且每行 lower 不得大于 upper。当前 schema 不开放自定义箱线百分位、直方图分箱和分布拟合参数,也不允许把这五类统计图作为 `multi_panel.series[].kind`;在目标 Origin 版本完成真机验证后再增量开放。

View File

@ -32,6 +32,13 @@ class OutputSpec:
required: bool
@dataclass(frozen=True)
class WorkspaceSpec:
state_output: str
preview_outputs: tuple[str, ...]
required_adapter_version: str
@dataclass(frozen=True)
class CapabilityContract:
capability: str
@ -45,6 +52,7 @@ class CapabilityContract:
features: dict[str, str]
summary: dict[str, Any]
legacy_runtime: dict[str, Any] | None
workspace: WorkspaceSpec | None
def normalize_request(self, request: object) -> tuple[dict[str, Any], str]:
errors = sorted(
@ -162,6 +170,7 @@ def _load_contract(path: Path) -> CapabilityContract:
required = {
"capability", "display_name", "default_enrollment", "output_namespace", "request_schema",
"input_policy", "outputs", "feature_path", "features", "summary", "legacy_runtime",
"workspace",
}
if not isinstance(raw, dict) or set(raw) != required:
raise RuntimeError(f"invalid software contract fields: {path.name}")
@ -199,6 +208,33 @@ def _load_contract(path: Path) -> CapabilityContract:
outputs[output_id] = OutputSpec(output_id=output_id, **item)
if len({item.relative_path for item in outputs.values()}) != len(outputs):
raise RuntimeError(f"duplicate output paths: {path.name}")
workspace_raw = raw["workspace"]
workspace: WorkspaceSpec | None = None
if workspace_raw is not None:
if not isinstance(workspace_raw, dict) or set(workspace_raw) != {
"state_output", "preview_outputs", "required_adapter_version"
}:
raise RuntimeError(f"invalid workspace spec: {path.name}")
state_output = workspace_raw["state_output"]
preview_outputs = workspace_raw["preview_outputs"]
required_adapter_version = workspace_raw["required_adapter_version"]
if (
not isinstance(state_output, str)
or state_output not in outputs
or not outputs[state_output].required
or not isinstance(preview_outputs, list)
or not preview_outputs
or any(not isinstance(item, str) or item not in outputs for item in preview_outputs)
or len(set(preview_outputs)) != len(preview_outputs)
or any(not outputs[item].required for item in preview_outputs)
or state_output in preview_outputs
or not isinstance(required_adapter_version, str)
or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", required_adapter_version)
):
raise RuntimeError(f"unsafe workspace spec: {path.name}")
workspace = WorkspaceSpec(
state_output, tuple(preview_outputs), required_adapter_version
)
Draft202012Validator.check_schema(raw["request_schema"])
return CapabilityContract(
capability=capability,
@ -212,6 +248,7 @@ def _load_contract(path: Path) -> CapabilityContract:
features=raw["features"],
summary=raw["summary"],
legacy_runtime=raw["legacy_runtime"],
workspace=workspace,
)
@ -317,7 +354,14 @@ def node_supports_request(
feature = contract.feature(request)
if isinstance(advertised_features, list) and feature not in advertised_features:
return False
return version_at_least(actual_version, contract.required_adapter_version(request))
required_version = contract.required_adapter_version(request)
if contract.workspace is not None and version_at_least(
contract.workspace.required_adapter_version, required_version
):
required_version = contract.workspace.required_adapter_version
return version_at_least(actual_version, required_version)
if contract.workspace is not None:
return False
# 兼容尚未升级 capability_runtime 的 Node兼容路径由版本化 contract 声明。
legacy_config = contract.legacy_runtime or {}
if not legacy_config:

View File

@ -17,11 +17,19 @@ from core.software_contracts import (
node_supports_request,
)
from core.storage.engine import session_scope
from core.storage.models import Artifact, SoftwareJob, SoftwareNode, Task
from core.storage.models import (
Artifact,
SoftwareJob,
SoftwareNode,
SoftwareWorkspace,
Task,
)
OFFER_SECONDS = 60
MAX_OUTPUT_ARTIFACT_BYTES = 256 * 1024 * 1024
MAX_OUTPUT_TOTAL_BYTES = 512 * 1024 * 1024
MAX_EXPORT_ARTIFACT_BYTES = 10 * 1024 * 1024 * 1024
MAX_EXPORT_TOTAL_BYTES = 20 * 1024 * 1024 * 1024
class SoftwareJobError(Exception):
@ -82,6 +90,12 @@ def _job_dict(row: SoftwareJob) -> dict:
return {
"job_id": str(row.job_id),
"task_id": str(row.task_id),
"workspace_id": (
str(row.workspace_id) if getattr(row, "workspace_id", None) else None
),
"source_job_id": (
str(row.source_job_id) if getattr(row, "source_job_id", None) else None
),
"capability": row.capability,
"request_digest": row.request_digest,
"node_id": str(row.node_id) if row.node_id else None,
@ -92,9 +106,17 @@ def _job_dict(row: SoftwareJob) -> dict:
"execution_runtime": _job_execution_runtime(row),
"error": row.error,
"artifact_manifest": row.artifact_manifest,
"local_manifest": getattr(row, "local_manifest", []),
"preview_manifest": getattr(row, "preview_manifest", []),
"export_status": getattr(row, "export_status", "none"),
"export_outputs": getattr(row, "export_outputs", []),
"completion_action": getattr(row, "completion_action", "report"),
"followup_status": getattr(row, "followup_status", "none"),
"output_dir": f"{contract.output_namespace}/{row.job_id}",
"output_dir": (
None
if contract.workspace is not None and getattr(row, "workspace_id", None)
else f"{contract.output_namespace}/{row.job_id}"
),
"created_at": row.created_at.isoformat() if row.created_at else None,
"started_at": row.started_at.isoformat() if row.started_at else None,
"terminal_at": row.terminal_at.isoformat() if row.terminal_at else None,
@ -227,6 +249,8 @@ def create_job(
capability: str,
request: dict,
completion_action: str = "report",
workspace_id: UUID | None = None,
source_job_id: UUID | None = None,
) -> tuple[dict, bool]:
key = idempotency_key.strip()
if not key or len(key) > 200:
@ -244,6 +268,70 @@ def create_job(
).first()
if task is None:
raise SoftwareJobError("task not found")
existing = session.execute(
select(SoftwareJob).where(
SoftwareJob.user_id == user_id,
SoftwareJob.idempotency_key == key,
)
).scalar_one_or_none()
if existing is not None:
if (
existing.task_id != task_id
or existing.capability != capability
or existing.request_digest != digest
or getattr(existing, "completion_action", "report") != completion_action
or workspace_id is not None and existing.workspace_id != workspace_id
or source_job_id is not None and existing.source_job_id != source_job_id
):
raise SoftwareJobError(
"idempotency key was already used for a different request"
)
return _job_dict(existing), False
if (workspace_id is None) != (source_job_id is None):
raise SoftwareJobError(
"workspace_id and source_job_id must be provided together"
)
workspace: SoftwareWorkspace | None = None
source: SoftwareJob | None = None
if workspace_id is not None:
workspace = session.execute(
select(SoftwareWorkspace).where(
SoftwareWorkspace.workspace_id == workspace_id,
SoftwareWorkspace.user_id == user_id,
).with_for_update()
).scalar_one_or_none()
if workspace is None:
raise SoftwareJobError("software workspace not found")
if workspace.capability != capability:
raise SoftwareJobError("software workspace capability does not match")
if workspace.status in {"deleting", "deleted", "lost"}:
raise SoftwareJobError("software workspace is not available")
source = session.execute(
select(SoftwareJob).where(
SoftwareJob.job_id == source_job_id,
SoftwareJob.workspace_id == workspace_id,
SoftwareJob.user_id == user_id,
)
).scalar_one_or_none()
if source is None or source.status != "succeeded":
raise SoftwareJobError("source software job is not a successful workspace revision")
if workspace.head_job_id != source.job_id:
raise SoftwareJobError("source software job is not the workspace head")
if source.export_status == "pending":
raise SoftwareJobError(
"software workspace must finish its pending export before revision"
)
active = session.execute(
select(SoftwareJob.job_id).where(
SoftwareJob.workspace_id == workspace_id,
SoftwareJob.status.in_({
"queued", "offered", "dispatched", "running",
"disconnected", "cancelling",
}),
).limit(1)
).first()
if active is not None:
raise SoftwareJobError("software workspace already has an active job")
bindings = contract.input_bindings(normalized)
artifact_ids = [UUID(item["artifact_id"]) for item in bindings]
artifacts = session.execute(
@ -302,13 +390,32 @@ def create_job(
or existing.capability != capability
or existing.request_digest != digest
or getattr(existing, "completion_action", "report") != completion_action
or workspace_id is not None and existing.workspace_id != workspace_id
or source_job_id is not None and existing.source_job_id != source_job_id
):
raise SoftwareJobError("idempotency key was already used for a different request")
return _job_dict(existing), False
effective_workspace_id = (
workspace_id
or (uuid4() if contract.workspace is not None else None)
)
new_workspace: SoftwareWorkspace | None = None
if workspace is None and effective_workspace_id is not None:
new_workspace = SoftwareWorkspace(
workspace_id=effective_workspace_id,
user_id=user_id,
capability=capability,
status="pending",
size_bytes=0,
state_manifest=[],
retention_until=datetime.now(timezone.utc) + timedelta(days=30),
)
row = SoftwareJob(
job_id=uuid4(),
user_id=user_id,
task_id=task_id,
workspace_id=effective_workspace_id,
source_job_id=source_job_id,
idempotency_key=key,
capability=capability,
request=normalized,
@ -319,11 +426,17 @@ def create_job(
metrics={},
error={},
artifact_manifest=[],
local_manifest=[],
preview_manifest=[],
export_status="none",
export_outputs=[],
completion_action=completion_action,
followup_status="none",
)
try:
with session.begin_nested():
if new_workspace is not None:
session.add(new_workspace)
session.add(row)
session.flush()
return _job_dict(row), True
@ -339,6 +452,8 @@ def create_job(
or existing.capability != capability
or existing.request_digest != digest
or getattr(existing, "completion_action", "report") != completion_action
or workspace_id is not None and existing.workspace_id != workspace_id
or source_job_id is not None and existing.source_job_id != source_job_id
):
raise SoftwareJobError(
"idempotency key was already used for a different request"
@ -389,7 +504,12 @@ def revise_job(
).scalar_one_or_none()
if source is None:
raise SoftwareJobError("source software job not found")
if source.status != "succeeded" or source.workspace_id is None:
raise SoftwareJobError("source software job has no available workspace state")
capability = source.capability
if get_contract(capability).workspace is None:
raise SoftwareJobError("capability does not support workspace continuation")
workspace_id = source.workspace_id
completion_action = getattr(source, "completion_action", "report")
source_request = source.request
inputs = deepcopy(source_request.get("inputs") or [])
@ -402,6 +522,8 @@ def revise_job(
idempotency_key=idempotency_key,
capability=capability,
completion_action=completion_action,
workspace_id=workspace_id,
source_job_id=source_job_id,
request={
"schema_version": schema_version,
"inputs": inputs,
@ -430,6 +552,122 @@ def request_job_analysis(user_id: UUID, job_id: UUID) -> dict:
return _job_dict(job)
def request_job_export(user_id: UUID, job_id: UUID, output_ids: list[str]) -> dict:
"""请求原 Node 将当前 Workspace head 的指定本地输出发布为正式 Artifact。"""
requested = list(dict.fromkeys(output_ids))
if not requested or len(requested) > 16 or any(not isinstance(item, str) for item in requested):
raise SoftwareJobError("export output_ids must contain 1 to 16 output identities")
with session_scope() as session:
job = session.execute(
select(SoftwareJob).where(
SoftwareJob.job_id == job_id,
SoftwareJob.user_id == user_id,
).with_for_update()
).scalar_one_or_none()
if job is None:
raise SoftwareJobError("job not found")
contract = get_contract(job.capability)
if job.status != "succeeded" or contract.workspace is None or job.workspace_id is None:
raise SoftwareJobError("job has no exportable workspace state")
workspace = session.execute(
select(SoftwareWorkspace).where(
SoftwareWorkspace.workspace_id == job.workspace_id
).with_for_update()
).scalar_one_or_none()
if workspace is None or workspace.head_job_id != job.job_id:
raise SoftwareJobError("only the current workspace head can be exported")
active = session.execute(
select(SoftwareJob.job_id).where(
SoftwareJob.workspace_id == job.workspace_id,
SoftwareJob.job_id != job.job_id,
SoftwareJob.status.in_({
"queued", "offered", "dispatched", "running",
"disconnected", "cancelling",
}),
).limit(1)
).first()
if active is not None:
raise SoftwareJobError("software workspace has an active revision")
local_by_id = {
item.get("artifact_id"): item
for item in job.local_manifest
if isinstance(item, dict) and isinstance(item.get("artifact_id"), str)
}
already_exported = {
item.get("source_artifact_id")
for item in job.artifact_manifest
if isinstance(item, dict) and item.get("artifact_id")
}
for output_id in requested:
if output_id not in local_by_id:
raise SoftwareJobError(f"local output is unavailable: {output_id}")
if not contract.output_spec(output_id).publish:
raise SoftwareJobError(f"output is internal and cannot be exported: {output_id}")
pending = [item for item in requested if item not in already_exported]
if not pending:
job.export_status = "completed"
return _job_dict(job)
if job.export_status == "pending":
if pending == list(job.export_outputs):
return _job_dict(job)
raise SoftwareJobError("software job already has a pending export")
job.export_outputs = pending
job.export_status = "pending"
return _job_dict(job)
def pending_node_exports(node_id: UUID) -> list[dict]:
with session_scope() as session:
jobs = session.execute(
select(SoftwareJob).where(
SoftwareJob.node_id == node_id,
SoftwareJob.status == "succeeded",
SoftwareJob.export_status == "pending",
).order_by(SoftwareJob.updated_at, SoftwareJob.job_id)
).scalars()
return [
{
"job_id": str(job.job_id),
"lease_id": str(job.lease_id),
"request_digest": job.request_digest,
"output_ids": list(job.export_outputs),
}
for job in jobs
if job.lease_id is not None
]
def record_job_export(
node_id: UUID, job_id: UUID, output_ids: list[str], published: list[dict]
) -> dict:
with session_scope() as session:
job = session.execute(
select(SoftwareJob).where(SoftwareJob.job_id == job_id).with_for_update()
).scalar_one_or_none()
if (
job is None
or job.node_id != node_id
or job.status != "succeeded"
or job.export_status not in {"pending", "completed"}
):
raise SoftwareJobError("software export request is stale")
requested = list(job.export_outputs)
if requested != output_ids:
raise SoftwareJobError("software export outputs do not match the request")
by_source = {
item.get("source_artifact_id"): item
for item in job.artifact_manifest
if isinstance(item, dict)
}
for item in published:
by_source[item["source_artifact_id"]] = item
job.artifact_manifest = list(by_source.values())
job.export_status = "completed"
job.completion_action = "report"
job.followup_status = "pending"
return _job_dict(job)
def offer_next_job(node_ids: set[UUID]) -> dict | None:
"""选择最早可执行的 JobNode 组合,避免跨 capability 队首阻塞。"""
if not node_ids:
@ -487,10 +725,23 @@ def offer_next_job(node_ids: set[UUID]) -> dict | None:
contract = get_contract(candidate.capability)
except SoftwareContractError:
continue
workspace = (
session.get(SoftwareWorkspace, candidate.workspace_id)
if getattr(candidate, "workspace_id", None) is not None
else None
)
if getattr(candidate, "workspace_id", None) is not None and (
workspace is None
or workspace.status in {"deleting", "deleted", "lost"}
or (workspace.head_job_id is not None and workspace.home_node_id is None)
):
continue
home_node_id = workspace.home_node_id if workspace is not None else None
node = next(
(
item for item in available_nodes
if candidate.capability in (item.capabilities or [])
and (home_node_id is None or item.node_id == home_node_id)
and node_supports_request(
contract, candidate.request, item.runtime or {}
)
@ -519,6 +770,11 @@ def offer_next_job(node_ids: set[UUID]) -> dict | None:
job.lease_id = lease_id
job.lease_expires_at = expires_at
job.status = "offered"
workspace = (
session.get(SoftwareWorkspace, job.workspace_id)
if getattr(job, "workspace_id", None) is not None
else None
)
return {
"node_id": node.node_id,
"payload": {
@ -528,6 +784,17 @@ def offer_next_job(node_ids: set[UUID]) -> dict | None:
"capability": job.capability,
"request_digest": job.request_digest,
"request": job.request,
"workspace": (
{
"workspace_id": str(job.workspace_id),
"source_job_id": (
str(job.source_job_id) if job.source_job_id else None
),
"mode": "continue" if job.source_job_id else "new",
}
if workspace is not None
else None
),
"input_transfers": [
{
**item,
@ -603,6 +870,11 @@ def get_job_output_context(node_id: UUID, job_id: UUID, lease_id: UUID, digest:
"request": job.request,
"status": job.status,
"artifact_manifest": job.artifact_manifest,
"local_manifest": getattr(job, "local_manifest", []),
"preview_manifest": getattr(job, "preview_manifest", []),
"workspace_id": getattr(job, "workspace_id", None),
"export_status": getattr(job, "export_status", "none"),
"export_outputs": getattr(job, "export_outputs", []),
}
@ -755,6 +1027,19 @@ def respond_to_offer(node_id: UUID, *, accepted: bool, payload: dict) -> None:
job.status = "dispatched"
job.stage = "accepted"
job.error = {}
if getattr(job, "workspace_id", None) is not None:
workspace = session.execute(
select(SoftwareWorkspace).where(
SoftwareWorkspace.workspace_id == job.workspace_id
).with_for_update()
).scalar_one_or_none()
if workspace is None:
raise SoftwareJobError("software workspace no longer exists")
if workspace.home_node_id not in {None, node_id}:
raise SoftwareJobError("software workspace belongs to another node")
workspace.home_node_id = node_id
if workspace.status == "pending":
workspace.status = "assigned"
snapshot = _execution_runtime_snapshot(
session.get(SoftwareNode, node_id), job.capability
)
@ -814,10 +1099,19 @@ def record_job_terminal(node_id: UUID, payload: dict) -> None:
raise SoftwareJobError("invalid job terminal status")
error = payload.get("error") or {}
manifest = payload.get("artifact_manifest") or []
local_manifest = payload.get("local_manifest") or manifest
preview_manifest = payload.get("preview_manifest") or []
if not isinstance(error, dict) or len(json.dumps(error, ensure_ascii=False)) > 64 * 1024:
raise SoftwareJobError("job terminal error is invalid")
if not isinstance(manifest, list) or len(json.dumps(manifest, ensure_ascii=False)) > 256 * 1024:
raise SoftwareJobError("job artifact manifest is invalid")
if (
not isinstance(local_manifest, list)
or len(json.dumps(local_manifest, ensure_ascii=False)) > 256 * 1024
or not isinstance(preview_manifest, list)
or len(json.dumps(preview_manifest, ensure_ascii=False)) > 64 * 1024
):
raise SoftwareJobError("job local or preview manifest is invalid")
now = datetime.now(timezone.utc)
with session_scope() as session:
job = session.execute(
@ -830,17 +1124,22 @@ def record_job_terminal(node_id: UUID, payload: dict) -> None:
job.request,
[
{
"artifact_id": item.get("source_artifact_id"),
"artifact_id": (
item.get("source_artifact_id") or item.get("artifact_id")
),
"filename": item.get("filename"),
"media_type": item.get("media_type"),
"size_bytes": item.get("size_bytes"),
"sha256": item.get("sha256"),
}
for item in manifest
for item in local_manifest
if isinstance(item, dict)
],
)
if len(expected) != len(manifest) or any(
contract = get_contract(job.capability)
if len(expected) != len(local_manifest):
raise SoftwareJobError("successful job local outputs are incomplete")
if contract.workspace is None and any(
not _published_output_is_valid(job.capability, job.job_id, item)
for item in manifest
):
@ -856,9 +1155,39 @@ def record_job_terminal(node_id: UUID, payload: dict) -> None:
job.progress = 100 if terminal_status == "succeeded" else job.progress
job.error = error
job.artifact_manifest = manifest
job.local_manifest = local_manifest
job.preview_manifest = preview_manifest
job.terminal_at = now
if terminal_status == "succeeded":
job.followup_status = "pending"
if getattr(job, "workspace_id", None) is not None:
workspace = session.execute(
select(SoftwareWorkspace).where(
SoftwareWorkspace.workspace_id == job.workspace_id
).with_for_update()
).scalar_one_or_none()
if workspace is None or workspace.home_node_id != node_id:
raise SoftwareJobError("software workspace ownership is inconsistent")
if job.source_job_id is not None and workspace.head_job_id != job.source_job_id:
raise SoftwareJobError("software workspace head changed during execution")
workspace.head_job_id = job.job_id
workspace.status = "active"
workspace.last_used_at = now
workspace.state_manifest = local_manifest
workspace.size_bytes = int(payload.get("workspace_size_bytes") or 0)
workspace.last_reported_at = now
workspace.retention_until = now + timedelta(days=30)
elif (
get_contract(job.capability).workspace is not None
and getattr(job, "workspace_id", None) is not None
):
workspace = session.execute(
select(SoftwareWorkspace).where(
SoftwareWorkspace.workspace_id == job.workspace_id
).with_for_update()
).scalar_one_or_none()
if workspace is not None and workspace.head_job_id is None:
workspace.status = "failed"
def _is_uuid(value: str) -> bool:

View File

@ -8,11 +8,11 @@ from hashlib import sha256
from uuid import UUID, uuid4
import bcrypt
from sqlalchemy import select
from sqlalchemy import select, update
from core.software_contracts import supported_capabilities
from core.storage.engine import session_scope
from core.storage.models import SoftwareNode, SoftwareNodeEnrollment
from core.storage.models import SoftwareNode, SoftwareNodeEnrollment, SoftwareWorkspace
MAX_ENROLLMENT_FAILURES = 5
@ -187,11 +187,16 @@ def set_node_disabled(node_id: UUID, disabled: bool) -> bool:
def delete_node(node_id: UUID) -> bool:
"""撤销并物理删除节点身份;当前节点表没有任务历史外键"""
"""撤销节点身份,并把无法再定位的持久 Workspace 标为丢失"""
with session_scope() as session:
node = session.get(SoftwareNode, node_id)
if node is None:
return False
session.execute(
update(SoftwareWorkspace)
.where(SoftwareWorkspace.home_node_id == node_id)
.values(status="lost", home_node_id=None)
)
session.delete(node)
return True

View File

@ -470,6 +470,53 @@ class SoftwareNode(Base):
)
class SoftwareWorkspace(Base):
"""专业软件在单一 Windows Node 上持有的可继续加工工程。"""
__tablename__ = "software_workspaces"
__table_args__ = (
Index("ix_software_workspaces_user_status", "user_id", "status"),
Index("ix_software_workspaces_node_status", "home_node_id", "status"),
)
workspace_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, default=uuid4
)
user_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False
)
capability: Mapped[str] = mapped_column(Text, nullable=False)
home_node_id: Mapped[Optional[UUID]] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("software_nodes.node_id", ondelete="SET NULL"),
nullable=True,
)
head_job_id: Mapped[Optional[UUID]] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey(
"software_jobs.job_id",
name="fk_software_workspaces_head_job_id",
ondelete="SET NULL",
use_alter=True,
),
nullable=True,
)
status: Mapped[str] = mapped_column(
Text, nullable=False, default="pending", server_default="pending"
)
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0, server_default="0")
state_manifest: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
last_reported_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
retention_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
class SoftwareJob(Base):
"""专业软件任务账本;请求只保存规范化参数和输入引用。"""
@ -488,6 +535,16 @@ class SoftwareJob(Base):
task_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("tasks.task_id", ondelete="CASCADE"), nullable=False
)
workspace_id: Mapped[Optional[UUID]] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("software_workspaces.workspace_id", ondelete="SET NULL"),
nullable=True,
)
source_job_id: Mapped[Optional[UUID]] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("software_jobs.job_id", ondelete="SET NULL"),
nullable=True,
)
idempotency_key: Mapped[str] = mapped_column(Text, nullable=False)
capability: Mapped[str] = mapped_column(Text, nullable=False)
request: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
@ -504,6 +561,12 @@ class SoftwareJob(Base):
metrics: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
error: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
artifact_manifest: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
local_manifest: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
preview_manifest: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
export_status: Mapped[str] = mapped_column(
Text, nullable=False, default="none", server_default="none"
)
export_outputs: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
# 0034:专业软件完成后的用户侧动作。report 只发布固定结果通知analyze 会在
# 原 task 空闲后自动续跑 Agent。followup_status 同时充当可恢复的轻量 outbox。
completion_action: Mapped[str] = mapped_column(

View File

@ -51,6 +51,7 @@ from tools.schedule import (
from tools.software_jobs import (
SoftwareCapabilityListTool,
SoftwareJobCancelTool,
SoftwareJobExportTool,
SoftwareJobReviseTool,
SoftwareJobStatusTool,
SoftwareJobSubmitTool,
@ -228,6 +229,7 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
SoftwareJobReviseTool(ctx.uid, ctx.task_id, **base),
SoftwareJobStatusTool(ctx.uid, ctx.task_id, **base),
SoftwareJobCancelTool(ctx.uid, ctx.task_id, **base),
SoftwareJobExportTool(ctx.uid, ctx.task_id, **base),
]
def _send_email() -> list:

View File

@ -0,0 +1,125 @@
"""Add persistent professional-software workspaces and job lineage.
Revision ID: 0035
Revises: 0034
Create Date: 2026-08-19
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0035"
down_revision: str | None = "0034"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"software_workspaces",
sa.Column("workspace_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("capability", sa.Text(), nullable=False),
sa.Column(
"home_node_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("software_nodes.node_id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"head_job_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey(
"software_jobs.job_id",
name="fk_software_workspaces_head_job_id",
ondelete="SET NULL",
),
nullable=True,
),
sa.Column("status", sa.Text(), nullable=False, server_default="pending"),
sa.Column("size_bytes", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("state_manifest", postgresql.JSONB(), nullable=False, server_default="[]"),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_reported_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("retention_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index(
"ix_software_workspaces_user_status",
"software_workspaces",
["user_id", "status"],
)
op.create_index(
"ix_software_workspaces_node_status",
"software_workspaces",
["home_node_id", "status"],
)
op.add_column(
"software_jobs",
sa.Column("workspace_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"software_jobs",
sa.Column("source_job_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"software_jobs",
sa.Column("local_manifest", postgresql.JSONB(), nullable=False, server_default="[]"),
)
op.add_column(
"software_jobs",
sa.Column("preview_manifest", postgresql.JSONB(), nullable=False, server_default="[]"),
)
op.add_column(
"software_jobs",
sa.Column("export_status", sa.Text(), nullable=False, server_default="none"),
)
op.add_column(
"software_jobs",
sa.Column("export_outputs", postgresql.JSONB(), nullable=False, server_default="[]"),
)
op.create_foreign_key(
"fk_software_jobs_workspace_id",
"software_jobs",
"software_workspaces",
["workspace_id"],
["workspace_id"],
ondelete="SET NULL",
)
op.create_foreign_key(
"fk_software_jobs_source_job_id",
"software_jobs",
"software_jobs",
["source_job_id"],
["job_id"],
ondelete="SET NULL",
)
op.create_index("ix_software_jobs_workspace_created", "software_jobs", ["workspace_id", "created_at"])
def downgrade() -> None:
op.drop_index("ix_software_jobs_workspace_created", table_name="software_jobs")
op.drop_constraint("fk_software_jobs_source_job_id", "software_jobs", type_="foreignkey")
op.drop_constraint("fk_software_jobs_workspace_id", "software_jobs", type_="foreignkey")
op.drop_column("software_jobs", "export_outputs")
op.drop_column("software_jobs", "export_status")
op.drop_column("software_jobs", "preview_manifest")
op.drop_column("software_jobs", "local_manifest")
op.drop_column("software_jobs", "source_job_id")
op.drop_column("software_jobs", "workspace_id")
op.drop_constraint(
"fk_software_workspaces_head_job_id",
"software_workspaces",
type_="foreignkey",
)
op.drop_index("ix_software_workspaces_node_status", table_name="software_workspaces")
op.drop_index("ix_software_workspaces_user_status", table_name="software_workspaces")
op.drop_table("software_workspaces")

View File

@ -75,6 +75,7 @@
"title_path": ["operation", "analysis", "title"]
},
"legacy_runtime": null,
"workspace": null,
"request_schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",

View File

@ -15,14 +15,14 @@
"media_type": "application/x-origin-project",
"relative_path": "project.opju",
"publish": true,
"required": false
"required": true
},
"figure_png": {
"filename": "figure.png",
"media_type": "image/png",
"relative_path": "figure.png",
"publish": true,
"required": false
"required": true
},
"figure_svg": {
"filename": "figure.svg",
@ -87,6 +87,11 @@
"slots_path": ["available_slots"],
"assumed_adapter_version": "0.3.0"
},
"workspace": {
"state_output": "project",
"preview_outputs": ["figure_png"],
"required_adapter_version": "1.0.0"
},
"request_schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",

View File

@ -39,9 +39,12 @@ class SoftwareContractTests(unittest.TestCase):
self.assertEqual(normalized, request)
self.assertEqual(len(digest), 64)
self.assertEqual(contract.output_namespace, "origin")
self.assertEqual(contract.workspace.state_output, "project")
self.assertEqual(contract.workspace.preview_outputs, ("figure_png",))
self.assertEqual(contract.workspace.required_adapter_version, "1.0.0")
self.assertEqual(
set(contract.expected_outputs(request)),
{"figure_png", "plot_spec", "provenance"},
{"project", "figure_png", "plot_spec", "provenance"},
)
self.assertEqual(DEFAULT_CAPABILITIES, ("origin.plot@v2",))
@ -58,7 +61,7 @@ class SoftwareContractTests(unittest.TestCase):
"origin.plot@v2": {
"health": "ready",
"available_slots": 1,
"adapter_version": "0.5.0",
"adapter_version": "1.0.0",
"features": ["line", "heatmap"],
}
}
@ -85,7 +88,7 @@ class SoftwareContractTests(unittest.TestCase):
})
self.assertFalse(node_supports_request(contract, multi_panel, current_runtime))
current_runtime["capability_runtime"]["origin.plot@v2"].update({
"adapter_version": "0.8.1",
"adapter_version": "1.0.0",
})
self.assertTrue(node_supports_request(contract, multi_panel, current_runtime))
@ -102,7 +105,7 @@ class SoftwareContractTests(unittest.TestCase):
})
self.assertFalse(node_supports_request(contract, recipe, current_runtime))
current_runtime["capability_runtime"]["origin.plot@v2"].update({
"adapter_version": "0.9.0",
"adapter_version": "1.0.0",
})
self.assertTrue(node_supports_request(contract, recipe, current_runtime))
@ -121,7 +124,7 @@ class SoftwareContractTests(unittest.TestCase):
self.assertEqual(normalized, bubble)
self.assertFalse(node_supports_request(contract, bubble, current_runtime))
current_runtime["capability_runtime"]["origin.plot@v2"].update({
"adapter_version": "0.7.0",
"adapter_version": "1.0.0",
"features": ["bubble"],
})
self.assertTrue(node_supports_request(contract, bubble, current_runtime))
@ -132,7 +135,7 @@ class SoftwareContractTests(unittest.TestCase):
self.assertEqual(normalized, area)
self.assertFalse(node_supports_request(contract, area, current_runtime))
current_runtime["capability_runtime"]["origin.plot@v2"].update({
"adapter_version": "0.8.0",
"adapter_version": "1.0.0",
"features": ["area"],
})
self.assertTrue(node_supports_request(contract, area, current_runtime))

View File

@ -11,6 +11,7 @@ from core.software_jobs import revise_job
from tools.software_jobs import (
SoftwareCapabilityListTool,
SoftwareJobCancelTool,
SoftwareJobExportTool,
SoftwareJobReviseTool,
SoftwareJobStatusTool,
SoftwareJobSubmitTool,
@ -197,6 +198,8 @@ class SoftwareJobToolTests(unittest.TestCase):
def test_revise_service_copies_inputs_and_revalidates_as_new_job(self):
source_job_id = uuid4()
source = SimpleNamespace(
status="succeeded",
workspace_id=uuid4(),
capability="origin.plot@v2",
request={
"schema_version": 2,
@ -238,6 +241,8 @@ class SoftwareJobToolTests(unittest.TestCase):
self.assertEqual(result, (created, True))
self.assertEqual(create.call_args.kwargs["capability"], "origin.plot@v2")
self.assertEqual(create.call_args.kwargs["completion_action"], "report")
self.assertEqual(create.call_args.kwargs["workspace_id"], source.workspace_id)
self.assertEqual(create.call_args.kwargs["source_job_id"], source_job_id)
self.assertEqual(create.call_args.kwargs["request"], {
"schema_version": 2,
"inputs": source.request["inputs"],
@ -262,6 +267,28 @@ class SoftwareJobToolTests(unittest.TestCase):
self.assertEqual(result["status"], "cancelled")
request_cancel.assert_called_once_with(self.user_id, job_id)
def test_export_requires_explicit_output_identities(self):
job_id = uuid4()
current = {"job_id": str(job_id), "task_id": str(self.task_id)}
exported = {
**current,
"export_status": "pending",
"export_outputs": ["project"],
}
with (
patch("tools.software_jobs.get_job", return_value=current),
patch(
"tools.software_jobs.request_job_export",
return_value=exported,
) as request_export,
):
result = json.loads(SoftwareJobExportTool(
self.user_id, self.task_id
).execute(str(job_id), ["project"]))
self.assertEqual(result["export_status"], "pending")
self.assertEqual(result["export_delivery"], "automatic")
request_export.assert_called_once_with(self.user_id, job_id, ["project"])
if __name__ == "__main__":
unittest.main()

View File

@ -217,6 +217,29 @@ class SoftwareNodeMigrationTests(unittest.TestCase):
self.assertIn("followup_status", rendered)
self.assertIn("ix_software_jobs_followup", rendered)
def test_0035_adds_workspace_lineage_preview_and_export_state(self) -> None:
statements: list[str] = []
def capture(sql, *multiparams, **params):
statements.append(str(sql.compile(dialect=postgresql.dialect())))
engine = create_mock_engine("postgresql+psycopg://", capture)
operations = Operations(MigrationContext.configure(engine.connect()))
migration = importlib.import_module(
"db.migrations.versions.20260819_0900_0035_software_workspaces"
)
with patch.object(migration, "op", operations):
migration.upgrade()
rendered = "\n".join(statements)
self.assertIn("software_workspaces", rendered)
self.assertIn("workspace_id", rendered)
self.assertIn("source_job_id", rendered)
self.assertIn("preview_manifest", rendered)
self.assertIn("local_manifest", rendered)
self.assertIn("export_status", rendered)
self.assertIn("ix_software_jobs_workspace_created", rendered)
class SoftwareJobProtocolTests(unittest.TestCase):
def test_succeeded_replay_uses_persisted_manifest_across_layout_versions(self) -> None:
@ -691,7 +714,7 @@ class SoftwareJobProtocolTests(unittest.TestCase):
node.node_id = uuid4()
node.capabilities = ["origin.plot@v2"]
node.runtime = {"capability_runtime": {"origin.plot@v2": {
"health": "ready", "available_slots": 1, "adapter_version": "0.5.0",
"health": "ready", "available_slots": 1, "adapter_version": "1.0.0",
"features": ["line"],
}}}
jobs = []
@ -854,6 +877,7 @@ class SoftwareNodeDeleteTests(unittest.TestCase):
session.get.return_value = node
self.assertTrue(delete_node(uuid4()))
session.execute.assert_called_once()
session.delete.assert_called_once_with(node)
@patch("core.software_nodes.session_scope")

View File

@ -7,10 +7,45 @@ from pathlib import Path
from unittest.mock import patch
from uuid import uuid4
from web.routers.software_nodes import _publish_software_job_outputs
from web.routers.software_nodes import (
_publish_software_job_outputs,
_publish_software_job_previews,
)
class SoftwareOutputPublishTests(unittest.TestCase):
def test_workspace_preview_uses_hidden_cache_without_artifact_registration(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
job_id = uuid4()
staging = root / ".zcbot_software_job_staging" / str(job_id)
staging.mkdir(parents=True)
content = b"preview"
(staging / "figure.png").write_bytes(content)
manifest = [{
"artifact_id": "figure_png",
"filename": "figure.png",
"media_type": "image/png",
"size_bytes": len(content),
"sha256": hashlib.sha256(content).hexdigest(),
}]
context = {
"capability": "origin.plot@v2",
"user_id": uuid4(),
}
with (
patch("web.routers.software_nodes.load_user_root", return_value=root),
patch("web.routers.software_nodes.register_published_artifacts") as register,
):
previews = _publish_software_job_previews(job_id, context, manifest)
self.assertFalse(register.called)
self.assertNotIn("artifact_id", previews[0])
self.assertEqual(previews[0]["preview_id"], "figure_png")
self.assertEqual(
(root / ".zcbot_cache" / "software-previews" / str(job_id) / "figure.png").read_bytes(),
content,
)
def test_complete_set_moves_atomically_and_can_be_replayed(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)

View File

@ -35,6 +35,18 @@ class WindowsNodeSourceTests(unittest.TestCase):
):
self.assertIn(marker, source)
def test_workspace_storage_is_global_by_workspace_not_user_directory(self) -> None:
models = (PROJECT / "NodeModels.cs").read_text(encoding="utf-8")
store = (PROJECT / "WorkspaceStore.cs").read_text(encoding="utf-8")
runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8")
self.assertIn('Path.Combine(root, "workspaces")', models)
self.assertIn('Binding(job).WorkspaceId.ToString("D")', store)
self.assertNotIn("UserId", store)
self.assertIn('Path.Combine(root, "current")', store)
self.assertIn('Path.Combine(root, "rollback")', store)
self.assertIn("workspaceStore.Promote", runner)
self.assertIn("workspaceStore.Restore", runner)
def test_runtime_auto_reports_all_discovered_adapter_capabilities(self) -> None:
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
config_store = (PROJECT / "NodeConfigStore.cs").read_text(encoding="utf-8")
@ -353,8 +365,9 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn('DefaultRequestHeaders.Add("X-Lease-Id"', uploader)
self.assertIn("SHA256.HashDataAsync", uploader)
self.assertIn("upload-complete.json", connection + uploader)
self.assertIn("UploadAsync(refreshed, recoveringOutputs)", connection)
self.assertIn("manifest, allowConflict: true", uploader)
self.assertIn("adapter.PreviewOutputIds", connection)
self.assertIn("adapter.WorkspaceStateFilename", connection)
self.assertIn("WorkspaceSize(jobDirectory), allowConflict: true", uploader)
self.assertIn("completeResponse.StatusCode == HttpStatusCode.Conflict", uploader)
self.assertIn("OpenOutputWithRetryAsync", uploader)
self.assertIn("IsSharingViolation", uploader)

View File

@ -17,6 +17,7 @@ from core.software_jobs import (
get_job_request,
list_jobs,
request_job_cancel,
request_job_export,
revise_job,
)
from core.software_nodes import list_nodes
@ -164,8 +165,9 @@ class SoftwareJobStatusTool(_SoftwareJobTool):
name = "software_job_status"
description = (
"Check one software job, or list recent jobs in the current task when job_id is omitted. "
"A succeeded job returns output_dir as the authoritative starting directory; "
"inspect or search within that directory to analyze its files."
"A succeeded workspace job returns preview_manifest and local_manifest. Local outputs "
"remain on the Windows node; call software_job_export only after an explicit user "
"request to download or deliver selected files."
)
parameters = {
"type": "object",
@ -195,8 +197,8 @@ class SoftwareJobReviseTool(_SoftwareJobTool):
name = "software_job_revise"
description = (
"Create a new professional-software job from a prior job in the current task. "
"Reuse the prior registered inputs, provide a complete replacement operation and "
"outputs, and leave the prior job and artifacts unchanged. Call software_job_status "
"Continue from the prior job's node-local workspace state, reuse its registered inputs, "
"and provide a complete next operation and outputs. Call software_job_status "
"first when the prior editable_request is not already in context. A successful revision "
"is the terminal action for the current run; completion and artifacts are delivered "
"automatically in a later system-managed message."
@ -276,3 +278,46 @@ class SoftwareJobCancelTool(_SoftwareJobTool):
return json.dumps(job, ensure_ascii=False)
except (ValueError, SoftwareJobError) as exc:
return f"[Error] {exc}"
class SoftwareJobExportTool(_SoftwareJobTool):
name = "software_job_export"
description = (
"Export selected node-local outputs from the current successful workspace head. "
"Call this only when the user explicitly asks to download, receive, or deliver files. "
"The transfer is asynchronous and completion is reported automatically."
)
parameters = {
"type": "object",
"properties": {
"job_id": {"type": "string", "format": "uuid"},
"output_ids": {
"type": "array",
"minItems": 1,
"maxItems": 16,
"uniqueItems": True,
"items": {"type": "string"},
"description": "Output identities from software_job_status.local_manifest.",
},
},
"required": ["job_id", "output_ids"],
"additionalProperties": False,
}
def execute(self, job_id: str, output_ids: list[str] | None = None) -> str:
try:
current = get_job(self.user_id, UUID(job_id.strip()))
if current is None or current["task_id"] != str(self.task_id):
return "[Error] software job not found"
job = request_job_export(
self.user_id,
UUID(job_id.strip()),
output_ids or [],
)
return json.dumps({
**job,
"export_delivery": "automatic",
"next_action": "end_turn_after_reporting_export_queued",
}, ensure_ascii=False)
except (ValueError, SoftwareJobError) as exc:
return f"[Error] {exc}"

View File

@ -30,6 +30,8 @@ from core.software_contracts import (
from core.software_jobs import (
MAX_OUTPUT_ARTIFACT_BYTES,
MAX_OUTPUT_TOTAL_BYTES,
MAX_EXPORT_ARTIFACT_BYTES,
MAX_EXPORT_TOTAL_BYTES,
SoftwareJobError,
abandon_offer,
create_job,
@ -40,6 +42,8 @@ from core.software_jobs import (
mark_node_jobs_disconnected,
offer_next_job,
pending_node_cancellations,
pending_node_exports,
record_job_export,
record_job_terminal,
replay_succeeded_outputs,
request_job_analysis,
@ -291,6 +295,121 @@ def _publish_software_job_outputs(job_id: UUID, context: dict, manifest: list[di
]
def _publish_software_job_previews(
job_id: UUID, context: dict, manifest: list[dict]
) -> list[dict]:
contract = get_contract(context["capability"])
if contract.workspace is None:
raise SoftwareJobError("capability does not define workspace previews")
preview_ids = set(contract.workspace.preview_outputs)
selected = [item for item in manifest if item["artifact_id"] in preview_ids]
if len(selected) != len(preview_ids):
raise SoftwareJobError("software job preview manifest is incomplete")
root = load_user_root(context["user_id"])
staging = safe_join(root, f".zcbot_software_job_staging/{job_id}")
destination = safe_join(root, f".zcbot_cache/software-previews/{job_id}")
_reject_symlink_path(root, staging)
_reject_symlink_path(root, destination)
destination.mkdir(parents=True, exist_ok=True)
previews: list[dict] = []
for item in selected:
source = staging / item["filename"]
target = destination / item["filename"]
if target.is_file():
if (
target.stat().st_size != item["size_bytes"]
or _hash_file(target) != item["sha256"]
):
raise SoftwareJobError("stored software preview conflicts with upload")
else:
if (
not source.is_file()
or source.stat().st_size != item["size_bytes"]
or _hash_file(source) != item["sha256"]
):
raise SoftwareJobError("uploaded software preview is missing or invalid")
os.replace(source, target)
previews.append({
"preview_id": item["artifact_id"],
"filename": item["filename"],
"media_type": item["media_type"],
"size_bytes": item["size_bytes"],
"sha256": item["sha256"],
"url": f"/v1/software-jobs/{job_id}/previews/{item['artifact_id']}",
})
if staging.is_dir():
try:
staging.rmdir()
staging.parent.rmdir()
except OSError:
pass
return previews
def _publish_software_job_export(
job_id: UUID, context: dict, manifest: list[dict]
) -> list[dict]:
capability = context["capability"]
contract = get_contract(capability)
root = load_user_root(context["user_id"])
working_dir = _task_working_dir(root, context["working_dir"])
staging = safe_join(root, f".zcbot_software_job_staging/{job_id}")
relative_output = Path(contract.output_namespace) / str(job_id)
destination = safe_join(working_dir, relative_output.as_posix())
_reject_symlink_path(root, staging)
_reject_symlink_path(root, destination)
destination.mkdir(parents=True, exist_ok=True)
for item in manifest:
source = staging / item["filename"]
target = destination / software_job_output_path(
capability, item["artifact_id"]
)
target.parent.mkdir(parents=True, exist_ok=True)
if target.is_file():
if target.stat().st_size != item["size_bytes"] or _hash_file(target) != item["sha256"]:
raise SoftwareJobError("published software export conflicts with upload")
else:
if (
not source.is_file()
or source.stat().st_size != item["size_bytes"]
or _hash_file(source) != item["sha256"]
):
raise SoftwareJobError("uploaded software export is missing or invalid")
os.replace(source, target)
refs = tuple({
"path": (
relative_output / software_job_output_path(capability, item["artifact_id"])
).as_posix(),
"label": item["filename"],
"media_type": item["media_type"],
} for item in manifest)
published_refs = register_published_artifacts(
user_id=context["user_id"],
task_id=context["task_id"],
user_root=root,
working_dir=working_dir,
refs=refs,
software_job_id=job_id,
)
refs_by_path = {item["path"]: item for item in published_refs}
return [
{
**item,
"source_artifact_id": item["artifact_id"],
"artifact_id": refs_by_path[
(
relative_output
/ software_job_output_path(capability, item["artifact_id"])
).as_posix()
]["artifact_id"],
"path": (
relative_output / software_job_output_path(capability, item["artifact_id"])
).as_posix(),
}
for item in manifest
]
def register_software_node_routes(app, *, require_user, require_admin) -> None:
@app.post(
"/v1/software-nodes/enroll",
@ -368,22 +487,49 @@ def register_software_node_routes(app, *, require_user, require_admin) -> None:
raise HTTPException(400, str(exc)) from exc
if artifact_id not in requested_ids:
raise HTTPException(400, "unsupported output artifact identity")
workspace_preview = (
contract.workspace is not None
and artifact_id in contract.workspace.preview_outputs
)
workspace_export = (
contract.workspace is not None
and context.get("export_status") == "pending"
and artifact_id in (context.get("export_outputs") or [])
)
if contract.workspace is not None and not (workspace_preview or workspace_export):
raise HTTPException(400, "only workspace previews may be uploaded automatically")
filename = output_spec.filename
if not 1 <= x_content_length <= MAX_OUTPUT_ARTIFACT_BYTES:
max_artifact_bytes = (
MAX_EXPORT_ARTIFACT_BYTES if workspace_export else MAX_OUTPUT_ARTIFACT_BYTES
)
if not 1 <= x_content_length <= max_artifact_bytes:
raise HTTPException(400, "output artifact size is invalid")
if len(x_content_sha256) != 64 or any(c not in "0123456789abcdef" for c in x_content_sha256):
raise HTTPException(400, "output artifact digest is invalid")
try:
if succeeded_output_upload_matches(
context,
artifact_id,
size_bytes=x_content_length,
digest=x_content_sha256,
):
return
except SoftwareJobError as exc:
raise HTTPException(409, str(exc)) from exc
if contract.workspace is None:
try:
if succeeded_output_upload_matches(
context,
artifact_id,
size_bytes=x_content_length,
digest=x_content_sha256,
):
return
except SoftwareJobError as exc:
raise HTTPException(409, str(exc)) from exc
root = load_user_root(context["user_id"])
if workspace_preview:
cached = safe_join(
root,
f".zcbot_cache/software-previews/{job_id}/{filename}",
)
if cached.is_file():
if (
cached.stat().st_size == x_content_length
and _hash_file(cached) == x_content_sha256
):
return
raise HTTPException(409, "stored preview conflicts with uploaded artifact")
working_dir = _task_working_dir(root, context["working_dir"])
published = safe_join(
working_dir,
@ -410,7 +556,8 @@ def register_software_node_routes(app, *, require_user, require_admin) -> None:
return
raise HTTPException(409, "uploaded output conflicts with existing staging file")
staged_total = sum(item.stat().st_size for item in staging.rglob("*") if item.is_file())
if staged_total + x_content_length > MAX_OUTPUT_TOTAL_BYTES:
max_total_bytes = MAX_EXPORT_TOTAL_BYTES if workspace_export else MAX_OUTPUT_TOTAL_BYTES
if staged_total + x_content_length > max_total_bytes:
raise HTTPException(413, "software job outputs exceed the total size limit")
temporary = destination.with_name(destination.name + ".tmp-" + os.urandom(8).hex())
digest = sha256()
@ -419,7 +566,7 @@ def register_software_node_routes(app, *, require_user, require_admin) -> None:
with temporary.open("xb") as handle:
async for chunk in request.stream():
total += len(chunk)
if total > x_content_length or total > MAX_OUTPUT_ARTIFACT_BYTES:
if total > x_content_length or total > max_artifact_bytes:
raise HTTPException(413, "output artifact exceeded declared size")
digest.update(chunk)
handle.write(chunk)
@ -452,6 +599,43 @@ def register_software_node_routes(app, *, require_user, require_admin) -> None:
manifest = validate_output_manifest(
context["capability"], context["request"], body.get("artifact_manifest")
)
contract = get_contract(context["capability"])
if contract.workspace is not None:
if context["status"] == "succeeded":
return {
"status": "succeeded",
"artifact_manifest": context.get("artifact_manifest") or [],
"preview_manifest": context.get("preview_manifest") or [],
}
workspace_size = body.get("workspace_size_bytes")
if (
not isinstance(workspace_size, int)
or isinstance(workspace_size, bool)
or workspace_size < 1
or workspace_size > 10 * 1024**4
):
raise SoftwareJobError("software workspace size is invalid")
previews = await asyncio.to_thread(
_publish_software_job_previews, job_id, context, manifest
)
terminal = {
"job_id": str(job_id),
"lease_id": str(lease_id),
"request_digest": x_request_digest,
"status": "succeeded",
"error": {},
"artifact_manifest": [],
"local_manifest": manifest,
"preview_manifest": previews,
"workspace_size_bytes": workspace_size,
}
await asyncio.to_thread(record_job_terminal, node_id, terminal)
await dispatch_followup(request.app, job_id)
return {
"status": "succeeded",
"artifact_manifest": [],
"preview_manifest": previews,
}
replayed = replay_succeeded_outputs(context, manifest)
if replayed is not None:
return {"status": "succeeded", "artifact_manifest": replayed}
@ -472,6 +656,105 @@ def register_software_node_routes(app, *, require_user, require_admin) -> None:
raise HTTPException(409, str(exc)) from exc
return {"status": "succeeded", "artifact_manifest": published}
@app.post(
"/v1/software-jobs/{job_id}/exports/complete",
tags=["software-nodes"],
)
async def complete_software_job_export(
job_id: UUID,
request: Request,
authorization: str | None = Header(default=None),
x_node_id: str = Header(default=""),
x_lease_id: str = Header(default=""),
x_request_digest: str = Header(default=""),
):
node_id, _, context = await asyncio.to_thread(
_authenticate_output_request,
job_id, authorization, x_node_id, x_lease_id, x_request_digest,
)
body = await request.json()
submitted = body.get("artifact_manifest") if isinstance(body, dict) else None
requested = context.get("export_outputs") or []
local_by_id = {
item.get("artifact_id"): item
for item in context.get("local_manifest") or []
if isinstance(item, dict)
}
if (
context.get("export_status") not in {"pending", "completed"}
or not isinstance(submitted, list)
or len(submitted) != len(requested)
or {item.get("artifact_id") for item in submitted if isinstance(item, dict)}
!= set(requested)
):
raise HTTPException(409, "software export manifest does not match the request")
identity_fields = ("filename", "media_type", "size_bytes", "sha256")
if any(
not isinstance(item, dict)
or item.get("artifact_id") not in local_by_id
or any(
item.get(field) != local_by_id[item["artifact_id"]].get(field)
for field in identity_fields
)
for item in submitted
):
raise HTTPException(409, "software export metadata changed after execution")
if context.get("export_status") == "completed":
return {
"status": "completed",
"artifact_manifest": context.get("artifact_manifest") or [],
}
try:
published = await asyncio.to_thread(
_publish_software_job_export, job_id, context, submitted
)
job = await asyncio.to_thread(
record_job_export, node_id, job_id, requested, published
)
await dispatch_followup(request.app, job_id)
return {"status": "completed", "artifact_manifest": job["artifact_manifest"]}
except (SoftwareJobError, KeyError, TypeError) as exc:
raise HTTPException(409, str(exc)) from exc
@app.get(
"/v1/software-jobs/{job_id}/previews/{preview_id}",
tags=["software-jobs"],
)
def read_software_job_preview(
job_id: UUID,
preview_id: str,
user_id: UUID = Depends(require_user), # noqa: B008
):
job = get_job(user_id, job_id)
if job is None:
raise HTTPException(404, "software job not found")
item = next(
(
value for value in job.get("preview_manifest") or []
if isinstance(value, dict) and value.get("preview_id") == preview_id
),
None,
)
if item is None:
raise HTTPException(404, "software job preview not found")
root = load_user_root(user_id)
target = safe_join(
root,
f".zcbot_cache/software-previews/{job_id}/{item['filename']}",
)
if (
not target.is_file()
or target.stat().st_size != item["size_bytes"]
or _hash_file(target) != item["sha256"]
):
raise HTTPException(404, "software job preview is unavailable")
return FileResponse(
path=str(target),
filename=item["filename"],
media_type=item["media_type"],
headers={"Cache-Control": "private, max-age=3600"},
)
@app.websocket("/v1/software-nodes/connect")
async def node_connect(websocket: WebSocket):
try:
@ -497,6 +780,10 @@ def register_software_node_routes(app, *, require_user, require_admin) -> None:
await node_connections.send_on(
node_id, websocket, {"type": "job_cancel", "payload": cancel}
)
for export in await asyncio.to_thread(pending_node_exports, node_id):
await node_connections.send_on(
node_id, websocket, {"type": "job_export", "payload": export}
)
while True:
message = await websocket.receive_json()
message_type = message.get("type")
@ -560,6 +847,10 @@ def register_software_node_routes(app, *, require_user, require_admin) -> None:
await node_connections.send_on(
node_id, websocket, {"type": "job_cancel", "payload": cancel}
)
for export in await asyncio.to_thread(pending_node_exports, node_id):
await node_connections.send_on(
node_id, websocket, {"type": "job_export", "payload": export}
)
offer = await asyncio.to_thread(
offer_next_job, await node_connections.node_ids()
)

View File

@ -39,8 +39,43 @@ def _artifact_refs(manifest: list) -> list[dict]:
return refs
def _preview_refs(manifest: list) -> list[dict]:
return [
{
"path": item["filename"],
"label": item["filename"],
"preview_url": item["url"],
"kind": "software_preview",
}
for item in manifest
if isinstance(item, dict)
and item.get("filename")
and item.get("url")
]
def _report_text(job: SoftwareJob) -> str:
contract = get_contract(job.capability)
if contract.workspace is not None and getattr(job, "workspace_id", None) is not None:
exported = [
str(item.get("filename"))
for item in job.artifact_manifest
if isinstance(item, dict) and item.get("artifact_id") and item.get("filename")
]
if exported:
return (
f"专业软件文件已导出:{contract.display_name}\n\n"
f"- Job ID`{job.job_id}`\n"
f"- 导出文件:{''.join(exported)}\n"
"- Workspace 工程状态仍保留在 Windows Node可继续加工"
)
return (
f"专业软件任务已完成:{contract.display_name}\n\n"
f"- Job ID`{job.job_id}`\n"
f"- Workspace ID`{job.workspace_id}`\n"
"- 工程状态:已保存在 Windows Node可继续加工\n"
"- 交付方式:需要源文件或最终格式时请明确提出导出"
)
output_dir = f"{contract.output_namespace}/{job.job_id}"
names = [
str(item.get("filename"))
@ -58,6 +93,14 @@ def _report_text(job: SoftwareJob) -> str:
def _analysis_prompt(job: SoftwareJob) -> str:
contract = get_contract(job.capability)
if contract.workspace is not None and getattr(job, "workspace_id", None) is not None:
return (
"[专业软件任务完成事件]\n"
f"任务 {job.job_id}{contract.display_name})已成功完成,工程保存在 "
f"Workspace {job.workspace_id}。请调用 software_job_status 获取输入、"
"本地输出清单和预览信息,结合原始数据向用户报告结果;只有用户明确要求"
"取回文件时才请求导出。"
)
output_dir = f"{contract.output_namespace}/{job.job_id}"
return (
"[专业软件任务完成事件]\n"
@ -130,7 +173,11 @@ def claim_followup(job_id: UUID) -> FollowupClaim | None:
task_id=task.task_id,
idx=next_idx,
payload={"role": "assistant", "content": _report_text(job)},
artifact_refs=_artifact_refs(job.artifact_manifest),
artifact_refs=(
_artifact_refs(job.artifact_manifest)
if job.artifact_manifest
else _preview_refs(job.preview_manifest)
),
kind="software_job_report",
))
job.followup_status = "completed"

View File

@ -191,6 +191,9 @@ export function renderArtifactBarHtml(rels, inlineMode = true, taskId = "", lega
const artifactAttr = ref.artifact_id
? ` data-artifact-id="${escapeHtml(String(ref.artifact_id))}"`
: "";
const previewAttr = ref.preview_url
? ` data-preview-url="${escapeHtml(String(ref.preview_url))}"`
: "";
if (!rel) return "";
const name = String(ref.label || rel.split("/").pop() || rel);
const cat = _categorize(rel);
@ -203,7 +206,7 @@ export function renderArtifactBarHtml(rels, inlineMode = true, taskId = "", lega
if (inlineMode === true && (cat === "image" || cat === "video")) {
// 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 <img>/<video>。
// 不在这里发请求避免 string-build 阶段失控的并发;upgrade 走 DOM walk 一次。
return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}"${taskAttr}${legacyAttr}${artifactAttr} title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}"${taskAttr}${legacyAttr}${artifactAttr}${previewAttr} title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
}
return `<button type="button" class="art-chip" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr}${artifactAttr} title="${escapeHtml(rel)} · 点击预览(可下载)">${renderArtifactChipContent(name, rel)}</button>`;
}).join("");
@ -239,6 +242,19 @@ function _fetchMediaBlobUrl(rel, taskId = "", legacy = false, artifactId = "") {
return p;
}
function _fetchPreviewBlobUrl(url) {
const key = `preview:${url}`;
if (_mediaArtifactCache.has(key)) return _mediaArtifactCache.get(key);
const p = fetch(url, {
headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => {
if (!r.ok) throw new Error("HTTP " + r.status);
return URL.createObjectURL(await r.blob());
});
_mediaArtifactCache.set(key, p);
return p;
}
function _fetchHtmlArtifact(rel, taskId = "", legacy = false, artifactId = "") {
const key = `${taskId}:${artifactId}:${legacy ? "legacy:" : ""}${rel}`;
if (_htmlArtifactCache.has(key)) return _htmlArtifactCache.get(key);
@ -312,7 +328,10 @@ export function upgradeMediaArtifacts(root) {
const taskId = node.dataset.taskId || "";
const legacy = node.dataset.legacyPath === "1";
const cat = node.dataset.cat;
_fetchMediaBlobUrl(rel, taskId, legacy, node.dataset.artifactId || "").then((url) => {
const load = node.dataset.previewUrl
? _fetchPreviewBlobUrl(node.dataset.previewUrl)
: _fetchMediaBlobUrl(rel, taskId, legacy, node.dataset.artifactId || "");
load.then((url) => {
node.innerHTML = "";
if (cat === "image") {
const img = document.createElement("img");

View File

@ -257,6 +257,7 @@ function jobCard(job) {
const error = job.error && (job.error.detail || job.error.code);
const artifacts = Array.isArray(job.artifact_manifest)
? job.artifact_manifest.filter((item) => item && item.artifact_id) : [];
const previews = Array.isArray(job.preview_manifest) ? job.preview_manifest : [];
const formats = (summary.formats || []).filter(Boolean).join("、");
const elapsed = elapsedText(job);
const shortId = String(job.job_id || "").slice(0, 8);
@ -268,14 +269,14 @@ function jobCard(job) {
${active ? `<div class="sj-progress"><i style="width:${progress}%"></i></div>` : ""}
<div class="sj-facts">
${inputNames ? `<span>输入:${escapeHtml(inputNames)}</span>` : ""}
${job.status === "succeeded" ? `<span>输出:${artifacts.length} 个文件${formats ? ` · ${escapeHtml(formats)}` : ""}</span>` : ""}
${job.status === "succeeded" ? `<span>${job.workspace_id ? `预览:${previews.length} 个 · 工程保存在节点` : `输出:${artifacts.length} 个文件${formats ? ` · ${escapeHtml(formats)}` : ""}`}</span>` : ""}
<span>完成方式${escapeHtml(followupLabel(job))}</span>
<span>${active ? `创建于 ${escapeHtml(fmtDate(job.created_at))}` : `${elapsed ? `耗时 ${escapeHtml(elapsed)} · ` : ""}${job.terminal_at ? `完成于 ${escapeHtml(fmtDate(job.terminal_at))}` : `创建于 ${escapeHtml(fmtDate(job.created_at))}`}`}</span>
</div>
<details class="sj-details"><summary>任务详情</summary><dl>
<div><dt>Job ID</dt><dd><code>${escapeHtml(job.job_id)}</code><button type="button" class="sj-copy" data-job-action="copy">${icons.copy}</button></dd></div>
<div><dt>Capability</dt><dd><code>${escapeHtml(job.capability)}</code></dd></div>
<div><dt>输出目录</dt><dd><code>${escapeHtml(job.output_dir || "")}</code></dd></div>
<div><dt>${job.workspace_id ? "Workspace ID" : "输出目录"}</dt><dd><code>${escapeHtml(job.workspace_id || job.output_dir || "")}</code></dd></div>
<div><dt>执行版本</dt><dd>${escapeHtml(jobRuntimeText(job))}</dd></div>
<div><dt>时间</dt><dd> ${escapeHtml(fmtDate(job.created_at))}<br> ${escapeHtml(fmtDate(job.started_at))}<br> ${escapeHtml(fmtDate(job.terminal_at))}</dd></div>
</dl></details>
@ -309,9 +310,14 @@ async function handleAction(event, button) {
}
const taskId = button.dataset.taskId;
if (action === "results") {
await openSoftwareJobResults(taskId, jobs.find((item) => item.job_id === jobId)?.output_dir || "");
setRightPanelTab("files");
if (mqPhone.matches) setMobileView("mv-right", "files");
const selected = jobs.find((item) => item.job_id === jobId);
if (selected?.workspace_id) {
await selectTask(taskId);
} else {
await openSoftwareJobResults(taskId, selected?.output_dir || "");
setRightPanelTab("files");
if (mqPhone.matches) setMobileView("mv-right", "files");
}
return;
}
if (action === "analyze") {

View File

@ -2,7 +2,7 @@
内网 MVP 的 Windows 执行节点,目标运行环境为 Windows 11 Enterprise + .NET 10 SDK 10.0.303。仓库根目录 `global.json` 固定 SDK patch客户端只使用 .NET Windows Desktop Framework不依赖第三方 NuGet 包。
当前实现托盘状态角标、配置与本机任务窗口、注册、DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连。Node Host 以语言无关的 job 目录协议负责持久化、下载、恢复、取消和上传,并从 EXE 同级 `adapters/*/adapter.json` 发现能力Host 只按 manifest 指向的 JSON Schema 通用校验请求,软件探测、语义校验和执行都由 Worker 完成。Worker 可为受管 Python 脚本或独立 EXEmanifest 独立声明每种软件的任务超时。当前安装包带 `origin.plot@v2` 和受真机验收门保护的 `ansys.mechanical.static_structural@v1` Python Worker但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录;成功产物按 manifest 上传并由云端复核,全部完成后原子发布,中断后按本地 `upload-complete.json` 幂等续传
当前实现托盘状态角标、配置与本机任务窗口、注册、DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连。Node Host 以语言无关的 job 目录协议负责持久化、下载、恢复、取消和上传,并从 EXE 同级 `adapters/*/adapter.json` 发现能力Host 只按 manifest 指向的 JSON Schema 通用校验请求,软件探测、语义校验和执行都由 Worker 完成。Worker 可为受管 Python 脚本或独立 EXEmanifest 独立声明每种软件的任务超时及可选 Workspace 状态。当前安装包带 `origin.plot@v2` 和受真机验收门保护的 `ansys.mechanical.static_structural@v1` Python Worker但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录;Origin 工程保存在 `workspaces/<workspace-id>/current`,只保留一份 `rollback`,完成时自动上传轻量预览,用户明确请求导出时才上传并发布所选正式产物
在仓库根目录执行一条命令生成可分发 ZIP
@ -41,4 +41,4 @@ windows-node/Zcbot.WindowsNode/bin/Debug/net10.0-windows/Zcbot.WindowsNode.exe
Zcbot.WindowsNode.exe run --headless
```
注册结果仍保存在 `%ProgramData%\Zcbot\WindowsNode\node.json`,可恢复任务保存在同级 `jobs`它们不随程序包更新。Token 使用 DPAPI `LocalMachine` 加密,目录与文件 ACL 只保留注册时的运行账号和 `SYSTEM`。复制配置到另一台机器不能解密 TokenWindows 重装、运行账号变更或身份丢失时应由管理员禁用旧节点并重新注册。
注册结果仍保存在 `%ProgramData%\Zcbot\WindowsNode\node.json`,可恢复任务保存在同级 `jobs`,持久工程保存在同级 `workspaces/<workspace-id>`;它们不随程序包更新。Workspace 目录不按 user ID 分层用户归属、home node、head Job 和容量由服务端数据库集中维护。Token 使用 DPAPI `LocalMachine` 加密,目录与文件 ACL 只保留注册时的运行账号和 `SYSTEM`。复制配置到另一台机器不能解密 TokenWindows 重装、运行账号变更或身份丢失时应由管理员禁用旧节点并重新注册。

View File

@ -10,6 +10,7 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo
{
private readonly ConcurrentDictionary<Guid, Task> active = new();
private readonly ConcurrentDictionary<Guid, CancellationTokenSource> cancellations = new();
private readonly WorkspaceStore workspaceStore = new(NodePaths.ForCurrentMachine());
internal bool HasActiveJobs => !active.IsEmpty;
internal string RuntimePath => ResolveCommand(descriptor.EntrypointPath).Filename;
@ -161,6 +162,8 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo
private async Task RunOnceAsync(RecoverableJob job, CancellationToken cancellationToken)
{
var workspacePrepared = false;
var workspacePromoted = false;
try
{
var paths = NodePaths.ForCurrentMachine();
@ -171,6 +174,10 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo
var markerPath = Path.Combine(jobDirectory, "worker-started.json");
if (File.Exists(markerPath))
{
if (descriptor.Contract.WorkspaceStateFilename is not null)
{
workspaceStore.Restore(job);
}
inbox.WriteTerminal(
job,
"failed",
@ -180,6 +187,11 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo
}
var command = ResolveCommand(descriptor.EntrypointPath);
if (descriptor.Contract.WorkspaceStateFilename is not null)
{
workspaceStore.Prepare(job, descriptor.Contract.Capability);
workspacePrepared = true;
}
WriteMarker(markerPath, command);
using var process = Start(command, jobDirectory, [jobDirectory]);
var stdout = process.StandardOutput.ReadToEndAsync();
@ -216,6 +228,18 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo
ErrorCode("WORKER_NO_TERMINAL"),
$"Adapter worker exited with code {process.ExitCode} without terminal.json.");
}
else if (descriptor.Contract.WorkspaceStateFilename is not null)
{
using var terminal = JsonDocument.Parse(File.ReadAllBytes(terminalPath));
if (terminal.RootElement.GetProperty("status").GetString() == "succeeded")
{
workspaceStore.Promote(
job,
descriptor.Contract.Capability,
descriptor.Contract.WorkspaceStateFilename);
workspacePromoted = true;
}
}
}
catch (Exception exception) when (
exception is IOException
@ -228,10 +252,15 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo
job,
"failed",
ErrorCode("WORKER_START_FAILED"),
exception.Message[..Math.Min(500, exception.Message.Length)]);
exception.Message[..Math.Min(500, exception.Message.Length)],
overwrite: true);
}
finally
{
if (workspacePrepared && !workspacePromoted)
{
workspaceStore.Restore(job);
}
active.TryRemove(job.JobId, out _);
if (cancellations.TryRemove(job.JobId, out var cancellation)) cancellation.Dispose();
}

View File

@ -44,7 +44,9 @@ internal sealed class JobInboxStore(string jobsDirectory)
|| !root.TryGetProperty("request_digest", out var digestValue)
|| digestValue.GetString() is not { Length: 64 } requestDigest
|| !root.TryGetProperty("capability", out var capabilityValue)
|| capabilityValue.GetString() is not { Length: > 0 } capability)
|| capabilityValue.GetString() is not { Length: > 0 } capability
|| !root.TryGetProperty("workspace", out var workspaceValue)
|| !TryReadWorkspace(workspaceValue, out var workspace))
{
continue;
}
@ -54,6 +56,7 @@ internal sealed class JobInboxStore(string jobsDirectory)
leaseId,
requestDigest,
capability,
workspace,
root.TryGetProperty("input_transfers", out var transfers)
? transfers.Clone() : null,
ReadTerminal(Path.Combine(jobDirectory, "terminal.json")),
@ -137,7 +140,9 @@ internal sealed class JobInboxStore(string jobsDirectory)
{
stage = "succeeded";
progress = 100;
detail = "结果已上传并由云端确认";
detail = File.Exists(Path.Combine(jobDirectory, "workspace-result.json"))
? "预览已上传,工程保存在本机工作区"
: "结果已上传并由云端确认";
updatedAt = LatestWrite(updatedAt, uploadPath);
}
snapshots.Add(new JobDisplaySnapshot(
@ -201,6 +206,8 @@ internal sealed class JobInboxStore(string jobsDirectory)
|| capabilityValue.GetString() is not { Length: > 0 } capability
|| !payload.TryGetProperty("request", out var request)
|| request.ValueKind != JsonValueKind.Object
|| !payload.TryGetProperty("workspace", out var workspaceValue)
|| !TryReadWorkspace(workspaceValue, out var workspace)
|| !payload.TryGetProperty("input_transfers", out var inputTransfers)
|| !IsValidInputTransfers(inputTransfers, jobId))
{
@ -211,6 +218,10 @@ internal sealed class JobInboxStore(string jobsDirectory)
{
return JobOfferResult.Reject("unsupported_request");
}
if ((adapter.WorkspaceStateFilename is null) != (workspace is null))
{
return JobOfferResult.Reject("workspace_protocol_mismatch");
}
if (!InputsMatchTransfers(request, inputTransfers))
{
return JobOfferResult.Reject("invalid_offer");
@ -242,6 +253,12 @@ internal sealed class JobInboxStore(string jobsDirectory)
capability,
accepted_at = DateTimeOffset.UtcNow,
request,
workspace = workspace is null ? null : new
{
workspace_id = workspace.WorkspaceId,
source_job_id = workspace.SourceJobId,
mode = workspace.Mode,
},
input_transfers = inputTransfers,
}, JsonOptions);
AtomicWrite(requestPath, updated, overwrite: true);
@ -263,6 +280,12 @@ internal sealed class JobInboxStore(string jobsDirectory)
capability,
accepted_at = DateTimeOffset.UtcNow,
request,
workspace = workspace is null ? null : new
{
workspace_id = workspace.WorkspaceId,
source_job_id = workspace.SourceJobId,
mode = workspace.Mode,
},
input_transfers = inputTransfers,
}, JsonOptions);
try
@ -509,10 +532,10 @@ internal sealed class JobInboxStore(string jobsDirectory)
}
internal void WriteTerminal(
RecoverableJob job, string status, string code, string detail)
RecoverableJob job, string status, string code, string detail, bool overwrite = false)
{
var path = Path.Combine(jobsDirectory, job.JobId.ToString("D"), "terminal.json");
if (File.Exists(path))
if (File.Exists(path) && !overwrite)
{
return;
}
@ -526,7 +549,7 @@ internal sealed class JobInboxStore(string jobsDirectory)
artifact_manifest = Array.Empty<object>(),
terminal_at = DateTimeOffset.UtcNow,
}, JsonOptions);
AtomicWrite(path, content, overwrite: false);
AtomicWrite(path, content, overwrite);
}
private static bool HasOnlyProperties(JsonElement value, params string[] allowed)
@ -542,6 +565,33 @@ internal sealed class JobInboxStore(string jobsDirectory)
&& property.ValueKind == JsonValueKind.String
&& Guid.TryParse(property.GetString(), out value);
}
private static bool TryReadWorkspace(
JsonElement value, out WorkspaceBinding? workspace)
{
workspace = null;
if (value.ValueKind == JsonValueKind.Null) return true;
if (value.ValueKind != JsonValueKind.Object
|| !HasOnlyProperties(value, "workspace_id", "source_job_id", "mode")
|| !TryReadGuid(value, "workspace_id", out var workspaceId)
|| !value.TryGetProperty("mode", out var modeValue)
|| modeValue.ValueKind != JsonValueKind.String)
{
return false;
}
var mode = modeValue.GetString();
if (mode is not ("new" or "continue")) return false;
Guid? sourceJobId = null;
if (value.TryGetProperty("source_job_id", out var sourceValue)
&& sourceValue.ValueKind != JsonValueKind.Null)
{
if (!Guid.TryParse(sourceValue.GetString(), out var parsed)) return false;
sourceJobId = parsed;
}
if ((mode == "new") != (sourceJobId is null)) return false;
workspace = new WorkspaceBinding(workspaceId, sourceJobId, mode);
return true;
}
}
internal sealed record RecoverableJob(
@ -549,6 +599,7 @@ internal sealed record RecoverableJob(
Guid LeaseId,
string RequestDigest,
string Capability,
WorkspaceBinding? Workspace,
JsonElement? InputTransfers,
JsonElement? Terminal,
bool UploadComplete);

View File

@ -19,7 +19,11 @@ internal sealed class JobOutputUploader(NodeConfig config)
TimeSpan.FromSeconds(5),
];
internal async Task UploadAsync(RecoverableJob job, bool recovery)
internal async Task UploadAsync(
RecoverableJob job,
bool recovery,
string? workspaceStateFilename,
IReadOnlyList<string> previewOutputIds)
{
var jobDirectory = Path.Combine(
NodePaths.ForCurrentMachine().JobsDirectory, job.JobId.ToString("D"));
@ -39,7 +43,7 @@ internal sealed class JobOutputUploader(NodeConfig config)
if (recovery)
{
var replay = await TryCompleteAsync(
client, jobDirectory, job, manifest, allowConflict: true);
client, jobDirectory, job, manifest, WorkspaceSize(jobDirectory), allowConflict: true);
if (replay is not null)
{
await WriteCompletionWithRetryAsync(job, completionPath, replay);
@ -51,10 +55,20 @@ internal sealed class JobOutputUploader(NodeConfig config)
foreach (var artifact in manifest.EnumerateArray())
{
var localId = artifact.GetProperty("artifact_id").GetString()!;
if (workspaceStateFilename is not null && !previewOutputIds.Contains(localId))
{
continue;
}
var filename = artifact.GetProperty("filename").GetString()!;
var expectedSize = artifact.GetProperty("size_bytes").GetInt64();
var expectedDigest = artifact.GetProperty("sha256").GetString()!;
var path = Path.Combine(jobDirectory, "output", filename);
var outputDirectory = workspaceStateFilename is null
? Path.Combine(jobDirectory, "output")
: Path.Combine(
NodePaths.ForCurrentMachine().WorkspacesDirectory,
job.Workspace!.WorkspaceId.ToString("D"),
"current");
var path = Path.Combine(outputDirectory, filename);
var info = new FileInfo(path);
if (!info.Exists || info.Length != expectedSize)
{
@ -90,7 +104,7 @@ internal sealed class JobOutputUploader(NodeConfig config)
}
var responseBody = await TryCompleteAsync(
client, jobDirectory, job, manifest, allowConflict: false)
client, jobDirectory, job, manifest, WorkspaceSize(jobDirectory), allowConflict: false)
?? throw new InvalidDataException("Output completion response is unavailable.");
await WriteCompletionWithRetryAsync(job, completionPath, responseBody);
Log(jobDirectory, "OK", $"Output upload completed job={job.JobId:D}");
@ -107,18 +121,97 @@ internal sealed class JobOutputUploader(NodeConfig config)
error: true);
}
internal async Task ExportAsync(RecoverableJob job, IReadOnlyList<string> outputIds)
{
var requested = outputIds.ToHashSet(StringComparer.Ordinal);
if (requested.Count == 0 || requested.Count != outputIds.Count)
{
throw new InvalidDataException("Export output identities are invalid.");
}
var jobDirectory = Path.Combine(
NodePaths.ForCurrentMachine().JobsDirectory, job.JobId.ToString("D"));
using var terminal = JsonDocument.Parse(
await File.ReadAllBytesAsync(Path.Combine(jobDirectory, "terminal.json")));
var selected = terminal.RootElement.GetProperty("artifact_manifest")
.EnumerateArray()
.Where(item => requested.Contains(item.GetProperty("artifact_id").GetString()!))
.Select(item => item.Clone())
.ToArray();
if (selected.Length != requested.Count)
{
throw new InvalidDataException("Requested local export is unavailable.");
}
using var client = new HttpClient { BaseAddress = config.ServerUrl };
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", config.NodeToken);
client.DefaultRequestHeaders.Add("X-Node-Id", config.NodeId.ToString());
client.DefaultRequestHeaders.Add("X-Lease-Id", job.LeaseId.ToString());
client.DefaultRequestHeaders.Add("X-Request-Digest", job.RequestDigest);
var outputDirectory = Path.Combine(
NodePaths.ForCurrentMachine().WorkspacesDirectory,
job.Workspace!.WorkspaceId.ToString("D"),
"current");
foreach (var artifact in selected)
{
var localId = artifact.GetProperty("artifact_id").GetString()!;
var filename = artifact.GetProperty("filename").GetString()!;
var expectedSize = artifact.GetProperty("size_bytes").GetInt64();
var expectedDigest = artifact.GetProperty("sha256").GetString()!;
var path = Path.Combine(outputDirectory, filename);
var info = new FileInfo(path);
if (!info.Exists || info.Length != expectedSize)
{
throw new InvalidDataException($"Export output is missing or changed: {localId}.");
}
await using (var verify = await OpenOutputWithRetryAsync(
jobDirectory, job, localId, filename, path, "verify"))
{
var digest = Convert.ToHexString(
await SHA256.HashDataAsync(verify)).ToLowerInvariant();
if (digest != expectedDigest)
{
throw new InvalidDataException($"Export output digest changed: {localId}.");
}
}
await using var stream = await OpenOutputWithRetryAsync(
jobDirectory, job, localId, filename, path, "transfer");
using var content = new StreamContent(stream);
content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
content.Headers.ContentLength = expectedSize;
content.Headers.Add("X-Content-SHA256", expectedDigest);
content.Headers.Add("X-Content-Length", expectedSize.ToString());
using var response = await client.PutAsync(
$"/v1/software-jobs/{job.JobId:D}/outputs/{Uri.EscapeDataString(localId)}",
content);
response.EnsureSuccessStatusCode();
}
using var completeContent = new StringContent(
JsonSerializer.Serialize(new { artifact_manifest = selected }),
Encoding.UTF8,
"application/json");
using var completeResponse = await client.PostAsync(
$"/v1/software-jobs/{job.JobId:D}/exports/complete", completeContent);
completeResponse.EnsureSuccessStatusCode();
Log(jobDirectory, "OK", $"On-demand export completed job={job.JobId:D}");
}
private static async Task<byte[]?> TryCompleteAsync(
HttpClient client,
string jobDirectory,
RecoverableJob job,
JsonElement manifest,
long? workspaceSizeBytes,
bool allowConflict)
{
Log(jobDirectory, "INFO",
$"Output upload phase=complete job={job.JobId:D} "
+ $"mode={(allowConflict ? "reconcile" : "publish")}");
using var completeContent = new StringContent(
JsonSerializer.Serialize(new { artifact_manifest = manifest }),
JsonSerializer.Serialize(new
{
artifact_manifest = manifest,
workspace_size_bytes = workspaceSizeBytes,
}),
Encoding.UTF8,
"application/json");
using var completeResponse = await client.PostAsync(
@ -134,6 +227,17 @@ internal sealed class JobOutputUploader(NodeConfig config)
return await completeResponse.Content.ReadAsByteArrayAsync();
}
private static long? WorkspaceSize(string jobDirectory)
{
var path = Path.Combine(jobDirectory, "workspace-result.json");
if (!File.Exists(path)) return null;
using var document = JsonDocument.Parse(File.ReadAllBytes(path));
return document.RootElement.TryGetProperty("size_bytes", out var value)
&& value.TryGetInt64(out var size)
? size
: null;
}
private static async Task<FileStream> OpenOutputWithRetryAsync(
string jobDirectory,
RecoverableJob job,

View File

@ -14,6 +14,8 @@ internal interface INodeAdapter
string RunningDetail { get; }
bool SupportsLocalAcceptance { get; }
string RuntimePath { get; }
string? WorkspaceStateFilename { get; }
IReadOnlyList<string> PreviewOutputIds { get; }
AdapterRuntimeStatus DetectRuntime();
Task<AdapterAcceptanceResult> RunLocalAcceptanceAsync(
string workRoot,
@ -43,7 +45,9 @@ internal sealed record NodeAdapterContract(
string Capability,
string DisplayName,
IReadOnlyList<string> Features,
JsonSchema RequestSchema);
JsonSchema RequestSchema,
string? WorkspaceStateFilename,
IReadOnlyList<string> PreviewOutputIds);
internal sealed record AdapterDescriptor(
string DirectoryPath,
@ -102,6 +106,24 @@ internal sealed record AdapterDescriptor(
.Where(item => ParseVersion(item.Value.GetString() ?? "0.0.0") <= currentVersion)
.Select(item => item.Name)
.ToArray();
string? workspaceStateFilename = null;
IReadOnlyList<string> previewOutputIds = [];
if (contractRoot.TryGetProperty("workspace", out var workspace)
&& workspace.ValueKind == JsonValueKind.Object)
{
var stateOutput = RequiredString(workspace, "state_output");
var outputs = contractRoot.GetProperty("outputs");
if (!outputs.TryGetProperty(stateOutput, out var stateSpec))
{
throw new InvalidDataException("Workspace state output is missing from contract.");
}
workspaceStateFilename = RequiredString(stateSpec, "filename");
previewOutputIds = workspace.GetProperty("preview_outputs")
.EnumerateArray()
.Select(item => item.GetString()
?? throw new InvalidDataException("Workspace preview output is invalid."))
.ToArray();
}
// JsonSchema may retain the source element for deferred evaluation. Give it an
// independent backing document before contractDocument is disposed.
var schema = JsonSchema.Build(contractRoot.GetProperty("request_schema").Clone());
@ -117,7 +139,13 @@ internal sealed record AdapterDescriptor(
root,
entrypointPath,
manifest,
new NodeAdapterContract(capability, displayName, features, schema));
new NodeAdapterContract(
capability,
displayName,
features,
schema,
workspaceStateFilename,
previewOutputIds));
}
private static string ResolveFile(string root, string relativePath)
@ -241,6 +269,8 @@ internal sealed class ProcessNodeAdapter : INodeAdapter
"ansys.mechanical.static_structural@v1", StringComparison.Ordinal)
&& File.Exists(Path.Combine(Descriptor.DirectoryPath, "acceptance.py"));
public string RuntimePath => runner.RuntimePath;
public string? WorkspaceStateFilename => Descriptor.Contract.WorkspaceStateFilename;
public IReadOnlyList<string> PreviewOutputIds => Descriptor.Contract.PreviewOutputIds;
public AdapterRuntimeStatus DetectRuntime()
{

View File

@ -18,6 +18,8 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
new JobInboxStore(NodePaths.ForCurrentMachine().JobsDirectory));
private readonly JobOutputUploader outputUploader = new(config);
private readonly ConcurrentDictionary<Guid, Task> jobPipelines = new();
private readonly ConcurrentDictionary<Guid, Task> exportPipelines = new();
private readonly WorkspaceStore workspaceStore = new(NodePaths.ForCurrentMachine());
private static readonly TimeSpan[] Backoff =
[
TimeSpan.FromSeconds(1),
@ -268,11 +270,69 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
artifact_manifest = Array.Empty<object>(),
}, cancellationToken);
}
else if (type.GetString() == "job_export"
&& document.RootElement.TryGetProperty("payload", out var exportPayload))
{
StartExport(exportPayload);
}
}
message.SetLength(0);
}
}
private void StartExport(JsonElement payload)
{
if (!payload.TryGetProperty("job_id", out var jobValue)
|| !Guid.TryParse(jobValue.GetString(), out var jobId)
|| !payload.TryGetProperty("lease_id", out var leaseValue)
|| !Guid.TryParse(leaseValue.GetString(), out var leaseId)
|| !payload.TryGetProperty("request_digest", out var digestValue)
|| !payload.TryGetProperty("output_ids", out var outputs)
|| outputs.ValueKind != JsonValueKind.Array)
{
return;
}
var job = jobInbox.ReadRecoverableJobs().SingleOrDefault(item =>
item.JobId == jobId
&& item.LeaseId == leaseId
&& item.RequestDigest == digestValue.GetString()
&& item.Terminal is JsonElement terminal
&& terminal.GetProperty("status").GetString() == "succeeded");
var outputIds = outputs.EnumerateArray()
.Select(item => item.GetString())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Cast<string>()
.ToArray();
if (job is null || outputIds.Length != outputs.GetArrayLength()) return;
if (exportPipelines.TryAdd(jobId, Task.CompletedTask))
{
var running = RunExportAndReleaseAsync(job, outputIds);
exportPipelines[jobId] = running;
}
}
private async Task RunExportAndReleaseAsync(
RecoverableJob job, IReadOnlyList<string> outputIds)
{
try
{
await outputUploader.ExportAsync(job, outputIds);
}
catch (Exception exception) when (
exception is HttpRequestException
or IOException
or JsonException
or UnauthorizedAccessException
or InvalidDataException)
{
outputUploader.RecordDeferred(job, exception);
}
finally
{
exportPipelines.TryRemove(job.JobId, out _);
}
}
private bool TryCancelJob(JsonElement payload, out RecoverableJob job)
{
job = null!;
@ -369,6 +429,10 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
}
if (terminal.GetProperty("status").GetString() != "succeeded")
{
if (adapter.WorkspaceStateFilename is not null)
{
workspaceStore.Restore(refreshed);
}
var terminalStatus = terminal.GetProperty("status").GetString() ?? "failed";
var terminalDetail = terminal.TryGetProperty("error", out var error)
&& error.TryGetProperty("detail", out var errorDetail)
@ -379,7 +443,17 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
await TrySendAsync(socket, "job_terminal", terminal);
return;
}
jobInbox.WriteState(refreshed, "uploading_outputs", 90, "软件执行完成,正在上传结果");
if (adapter.WorkspaceStateFilename is not null)
{
workspaceStore.EnsureSucceeded(
refreshed,
adapter.Capability,
adapter.WorkspaceStateFilename);
}
jobInbox.WriteState(refreshed, "uploading_outputs", 90,
adapter.WorkspaceStateFilename is null
? "软件执行完成,正在上传结果"
: "软件执行完成,正在上传预览");
await TrySendAsync(socket, "job_state", new
{
job_id = job.JobId,
@ -389,8 +463,15 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
progress = 90,
metrics = new { },
});
await outputUploader.UploadAsync(refreshed, recoveringOutputs);
jobInbox.WriteState(refreshed, "succeeded", 100, "结果已上传并由云端确认");
await outputUploader.UploadAsync(
refreshed,
recoveringOutputs,
adapter.WorkspaceStateFilename,
adapter.PreviewOutputIds);
jobInbox.WriteState(refreshed, "succeeded", 100,
adapter.WorkspaceStateFilename is null
? "结果已上传并由云端确认"
: "预览已上传,工程保存在本机工作区");
}
catch (Exception exception) when (
exception is HttpRequestException

View File

@ -34,7 +34,11 @@ internal sealed record EnrollResponse(
[property: JsonPropertyName("heartbeat_seconds")] int HeartbeatSeconds,
[property: JsonPropertyName("max_concurrency")] int MaxConcurrency);
internal sealed record NodePaths(string RootDirectory, string ConfigPath, string JobsDirectory)
internal sealed record NodePaths(
string RootDirectory,
string ConfigPath,
string JobsDirectory,
string WorkspacesDirectory)
{
internal static NodePaths ForCurrentMachine()
{
@ -42,10 +46,18 @@ internal sealed record NodePaths(string RootDirectory, string ConfigPath, string
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"Zcbot", "WindowsNode");
return new NodePaths(
root, Path.Combine(root, "node.json"), Path.Combine(root, "jobs"));
root,
Path.Combine(root, "node.json"),
Path.Combine(root, "jobs"),
Path.Combine(root, "workspaces"));
}
}
internal sealed class NodeConfigurationException(string message) : Exception(message);
internal sealed class NodeEndpointException(string message) : Exception(message);
internal sealed record WorkspaceBinding(
Guid WorkspaceId,
Guid? SourceJobId,
string Mode);

View File

@ -0,0 +1,166 @@
using System.Text.Json;
namespace Zcbot.WindowsNode;
internal sealed class WorkspaceStore(NodePaths paths)
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
internal string CurrentDirectory(RecoverableJob job) =>
Path.Combine(paths.WorkspacesDirectory, Binding(job).WorkspaceId.ToString("D"), "current");
internal void Prepare(RecoverableJob job, string capability)
{
var root = WorkspaceRoot(job);
var current = Path.Combine(root, "current");
var rollback = Path.Combine(root, "rollback");
Directory.CreateDirectory(root);
var binding = Binding(job);
if (binding.Mode == "continue")
{
var metadata = ReadMetadata(root);
if (metadata.Capability != capability
|| metadata.HeadJobId != binding.SourceJobId)
{
throw new InvalidDataException("Workspace head does not match the requested source job.");
}
if (!Directory.Exists(current))
{
if (Directory.Exists(rollback)) Directory.Move(rollback, current);
else throw new InvalidDataException("Workspace current state is missing.");
}
if (Directory.Exists(rollback)) Directory.Delete(rollback, recursive: true);
Directory.Move(current, rollback);
}
else if (File.Exists(Path.Combine(root, "workspace.json")) || Directory.Exists(current))
{
throw new InvalidDataException("A new workspace already contains state.");
}
var jobDirectory = Path.Combine(paths.JobsDirectory, job.JobId.ToString("D"));
AtomicJson(Path.Combine(jobDirectory, "workspace.json"), new
{
workspace_id = binding.WorkspaceId,
source_job_id = binding.SourceJobId,
mode = binding.Mode,
local_base_path = binding.Mode == "continue" ? rollback : null,
});
}
internal long Promote(RecoverableJob job, string capability, string stateFilename)
{
var root = WorkspaceRoot(job);
var current = Path.Combine(root, "current");
var output = Path.Combine(paths.JobsDirectory, job.JobId.ToString("D"), "output");
var statePath = Path.Combine(output, stateFilename);
if (!File.Exists(statePath))
{
throw new InvalidDataException("Adapter did not create the workspace state output.");
}
if (Directory.Exists(current))
{
throw new InvalidDataException("Workspace current destination already exists.");
}
Directory.Move(output, current);
return CommitCurrent(job, capability);
}
internal long EnsureSucceeded(
RecoverableJob job, string capability, string stateFilename)
{
var current = CurrentDirectory(job);
if (File.Exists(Path.Combine(current, stateFilename)))
{
return CommitCurrent(job, capability);
}
var output = Path.Combine(paths.JobsDirectory, job.JobId.ToString("D"), "output");
if (File.Exists(Path.Combine(output, stateFilename)))
{
return Promote(job, capability, stateFilename);
}
Restore(job);
throw new InvalidDataException("Successful workspace output cannot be recovered.");
}
private long CommitCurrent(RecoverableJob job, string capability)
{
var root = WorkspaceRoot(job);
var sizeBytes = Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)
.Where(item => !Path.GetFileName(item).Equals(
"workspace.json", StringComparison.OrdinalIgnoreCase))
.Sum(item => new FileInfo(item).Length);
AtomicJson(Path.Combine(root, "workspace.json"), new
{
workspace_id = Binding(job).WorkspaceId,
capability,
head_job_id = job.JobId,
size_bytes = sizeBytes,
updated_at = DateTimeOffset.UtcNow,
});
AtomicJson(
Path.Combine(paths.JobsDirectory, job.JobId.ToString("D"), "workspace-result.json"),
new { size_bytes = sizeBytes });
return sizeBytes;
}
internal void Restore(RecoverableJob job)
{
if (Binding(job).Mode != "continue") return;
var root = WorkspaceRoot(job);
var current = Path.Combine(root, "current");
var rollback = Path.Combine(root, "rollback");
if (!Directory.Exists(current) && Directory.Exists(rollback))
{
Directory.Move(rollback, current);
}
}
private string WorkspaceRoot(RecoverableJob job) =>
Path.Combine(paths.WorkspacesDirectory, Binding(job).WorkspaceId.ToString("D"));
private static WorkspaceBinding Binding(RecoverableJob job) =>
job.Workspace
?? throw new InvalidDataException("Job has no persistent workspace binding.");
private static WorkspaceMetadata ReadMetadata(string root)
{
var path = Path.Combine(root, "workspace.json");
using var document = JsonDocument.Parse(File.ReadAllBytes(path));
var value = document.RootElement;
if (!value.TryGetProperty("capability", out var capability)
|| !value.TryGetProperty("head_job_id", out var head)
|| !Guid.TryParse(head.GetString(), out var headJobId))
{
throw new InvalidDataException("Workspace metadata is invalid.");
}
return new WorkspaceMetadata(capability.GetString() ?? "", headJobId);
}
private static void AtomicJson(string path, object value)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var temporary = path + ".tmp-" + Guid.NewGuid().ToString("N");
try
{
using (var stream = new FileStream(
temporary,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
4096,
FileOptions.WriteThrough))
{
var content = JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions);
stream.Write(content);
stream.Flush(flushToDisk: true);
}
File.Move(temporary, path, overwrite: true);
}
finally
{
if (File.Exists(temporary)) File.Delete(temporary);
}
}
private sealed record WorkspaceMetadata(string Capability, Guid HeadJobId);
}

View File

@ -1,6 +1,6 @@
{
"capability": "origin.plot@v2",
"adapter_version": "0.9.4",
"adapter_version": "1.0.0",
"runtime": "python",
"runtime_id": "origin",
"entrypoint": "worker.py",

View File

@ -75,7 +75,7 @@ PANEL_LAYOUT = {
(2, 1): {"left": 13, "right": 13, "top": 14, "bottom": 12, "xgap": 0, "ygap": 10},
(2, 2): {"left": 9, "right": 4, "top": 14, "bottom": 12, "xgap": 11, "ygap": 10},
}
ADAPTER_VERSION = "0.9.4"
ADAPTER_VERSION = "1.0.0"
def _server_executable(command: str) -> Path:
@ -1256,7 +1256,22 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
output.mkdir(exist_ok=True)
op.set_show(False)
try:
op.new()
workspace_path = job_dir / "workspace.json"
workspace = (
json.loads(workspace_path.read_text(encoding="utf-8"))
if workspace_path.is_file()
else {}
)
base_path = workspace.get("local_base_path")
if workspace.get("mode") == "continue":
if not isinstance(base_path, str):
raise ValueError("WORKSPACE_BASE_MISSING")
base_project = Path(base_path) / "project.opju"
if not base_project.is_file():
raise ValueError("WORKSPACE_PROJECT_MISSING")
op.open(str(base_project))
else:
op.new()
_configure_origin_session(op)
worksheets: dict[str, Any] = {}
for input_spec in input_specs:
@ -1388,15 +1403,18 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
layer.label("Legend").text = band_legend
_apply_title(layer, plot_spec.get("title"), plot_spec.get("title_style"))
requested_outputs = request["outputs"]
formats = [item["format"] for item in requested_outputs]
formats = list(dict.fromkeys([
*[item["format"] for item in requested_outputs],
"opju",
"png",
]))
if any(item not in FORMATS for item in formats):
raise ValueError("OUTPUT_FORMAT_UNSUPPORTED")
artifacts: list[dict[str, Any]] = []
if "opju" in formats:
project = output / "project.opju"
op.save(str(project))
_validate_artifact(project, "opju")
artifacts.append(_manifest(project, "application/x-origin-project"))
project = output / "project.opju"
op.save(str(project))
_validate_artifact(project, "opju")
artifacts.append(_manifest(project, "application/x-origin-project"))
media = {"png": "image/png", "svg": "image/svg+xml", "pdf": "application/pdf"}
png_output = next((item for item in requested_outputs if item["format"] == "png"), None)
dpi = (png_output.get("options") or {}).get("dpi", 300) if png_output else 300