diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 704e04a0..f505ff6f 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -2253,3 +2253,60 @@ - 决策:P2 不直接跳到长驻 Docker 工作区或 Shell,而是先新增 `web_project_runtime_job`、worker / lease / controller、日志分页和 SSE 重连,再抽象 `SandboxRuntime`。第一版 SandboxRuntime 可继续复用当前 temp-dir runner,Docker / gVisor / Kata / microVM 作为后续执行面实现;snapshot 仍是唯一事实源,sandbox diff 必须转为 `WebProjectPatch` 并经过 api-server 校验后落库。 - 影响范围:`docs/planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md`、`docs/technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md`、`docs/technical/【技术方案】EditorAgentMockAgentP1落地计划-2026-06-15.md`、Web Project runtime job 后续实现。 - 验证方式:P2 落地时需覆盖 runtime job 状态机、worker claim / renew / complete / fail、failed / cancelled / expired / stale 不覆盖 active preview、日志分页 / SSE 重连、真实浏览器 happy path 和破坏构建保留上一版预览。 + +## 2026-06-17 Editor Agent P2 runtime job API 只能经 spacetime-client facade + +- 背景:P2-03 开始把 `web_project_runtime_job` 暴露给 api-server 和前端恢复链路;如果 api-server 直接拼 generated procedure 或下发 worker lease,会让后续 P2-04 / P2-05 的 worker 边界变脆。 +- 决策:Web Project runtime job 的 `create / get / list open / claim / renew / complete / fail / cancel / stale / expire / append log / list logs` 统一封装在 `server-rs/crates/spacetime-client/src/web_project.rs` facade。api-server HTTP 只暴露创建、读取、取消、未终态列表和日志分页;HTTP DTO 不下发 worker `lease_token`。P2-04 起 `POST /api/runtime/web-project/projects/{projectId}/preview-builds` 只创建 preview build 并入队 `web_project_runtime_job(kind=preview_build)`,不再在请求内抢占 preview build slot、启动 runner 或提前推进 `active_preview_build_id`。 +- 影响范围:`server-rs/crates/spacetime-client/src/web_project.rs`、`server-rs/crates/spacetime-client/src/mapper/web_project.rs`、`server-rs/crates/api-server/src/web_project.rs`、`shared-contracts`、`packages/shared`、P2 后续 worker / preview build 迁移。 +- 验证方式:`cargo test -p spacetime-client web_project --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server web_project_runtime_job --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run test -- src/services/web-project --reporter verbose`、`npm run test -- src/components/editor/agent/WebProjectAgentEditorPage.test.tsx --reporter verbose`、`npm run typecheck`、`npm run check:spacetime-schema`。 +- 关联文档:`docs/planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-06-17 Editor Agent P2 Runtime Worker 默认不伪造预览成功 + +- 背景:P2-05 需要先落地 Web Project runtime worker 的 claim / lease / log / complete / fail 骨架,但真实 SandboxRuntime 与 runner 写回将在 P2-06 接入;如果骨架阶段把 preview build 标成 succeeded,会让前端拿到没有 artifact / preview URL 的假成功。 +- 决策:新增 `GENARRATIVE_PROCESS_ROLE=web-project-runtime-worker`,并支持 `GENARRATIVE_PROCESS_ROLE=all` 与 HTTP 同进程并行启动。P2-05 worker 默认 `GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_DRY_RUN_RESULT=fail`,只验证领取、日志、续租和失败闭环;显式 `success` 只允许无 preview build 绑定的 runtime job 完成。preview build 绑定任务的 dry-run 失败会先通过 runtime job lease fail,再 best-effort 将 linked `web_project_preview_build` 标记 failed 并广播 SSE;该两步不是同一 SpacetimeDB transaction,后续若要强一致需补组合 procedure。 +- 影响范围:`server-rs/crates/api-server/src/web_project_runtime_worker.rs`、`server-rs/crates/api-server/src/main.rs`、`server-rs/crates/api-server/src/config.rs`、`server-rs/crates/api-server/src/web_project.rs`、P2-06 SandboxRuntime 接入。 +- 验证方式:`cargo test -p api-server web_project_runtime_worker --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 +- 关联文档:`docs/planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-06-17 Editor Agent P2 TempDirBuildRuntime 先接入 worker + +- 背景:P2-06 要把 P1 `web-project-runner` 从请求内后台任务迁到持久 runtime worker,同时为后续 Docker / gVisor / microVM 执行面留出 `SandboxRuntime` 抽象。 +- 决策:在 `api-server` 内新增 `web_project_preview_runtime`,定义第一版 `SandboxRuntime` trait 与 `TempDirBuildRuntime`,当前实现继续以受控子进程调用 `web-project-runner`,不直接开放 Shell,也不把 runner 改成 in-process 构建。worker 对 linked preview build job 追加 hydrate / build / artifact / preview 阶段日志,runner 成功后先 `complete_web_project_runtime_job` 复用 active snapshot stale 校验,再 best-effort 写 `web_project_preview_build=succeeded` 和 preview token / URL;runner 失败只失败当前 runtime job 和 linked preview build,不生成新 preview。 +- 一致性备注:runtime job 与 preview build 仍是两次 SpacetimeDB procedure,worker 在 runner 结束后先做最终续租再写终态,降低 lease 过期造成的部分写回风险;彻底单事务完成、失败和 stale 组合 procedure 或 reconcile 仍归入 P2-07 Active Preview Guard / Stale 收口。 +- 影响范围:`server-rs/crates/api-server/src/web_project_preview_runtime.rs`、`server-rs/crates/api-server/src/web_project_runtime_worker.rs`、`server-rs/crates/api-server/src/web_project.rs`、`docs/planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 +- 验证方式:`cargo test -p web-project-runner --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server web_project_runtime_worker --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server web_project --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 +- 关联文档:`docs/planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-06-17 Editor Agent P2 active preview 推进必须单事务完成 + +- 背景:P2-06 成功路径先完成 runtime job,再 best-effort 写 preview build succeeded;如果旧 snapshot job 在新 snapshot 成为 active 后才完成,或两次写回中间失败,可能留下 job/build 状态不一致或错误推进 preview 的风险。 +- 决策:P2-07 新增 `complete_web_project_preview_build_runtime_job_and_return` 组合 procedure。runner 成功后,worker 只生成 artifact / preview token / preview URL / logs 的 finish plan,再通过 `spacetime-client` facade 调用该 procedure;SpacetimeDB 在同一 transaction 内校验 worker lease、preview build 绑定和 `job.snapshot_id == web_project.active_snapshot_id`,然后同时写 runtime job、preview build 与 `active_preview_build_id`。若 snapshot 已 stale,即使 runner 成功也只把 runtime job 和 preview build 标为 `stale`,不生成 preview URL、不覆盖 active preview。 +- 影响范围:`server-rs/crates/spacetime-module/src/web_project.rs`、`server-rs/crates/spacetime-client/src/web_project.rs`、`server-rs/crates/spacetime-client/src/mapper/web_project.rs`、`server-rs/crates/api-server/src/web_project_runtime_worker.rs`、`server-rs/crates/api-server/src/web_project_preview_runtime.rs`、前端 stale 展示测试和 P2 文档。 +- 验证方式:`npm run spacetime:generate`、`cargo check -p spacetime-module --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`、`cargo test -p spacetime-client web_project --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server web_project_runtime_worker --manifest-path server-rs/Cargo.toml`、`npm run test -- src/services/web-project/webProjectSse.test.ts src/components/editor/agent/WebProjectAgentEditorPage.test.tsx --reporter verbose`、`npm run check:spacetime-schema`。 +- 关联文档:`docs/planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-06-17 Editor Agent P2 允许原生纯逻辑单测使用 SpacetimeDB ABI 桩 + +- 背景:`spacetime-module` 的 `runtime_job` 用例只验证 lease token、claimable、terminal status、stale 清理和失败结果结构等纯 Rust helper;但 Windows native `cargo test` 会链接整个 crate,`spacetimedb-bindings-sys` 的 WASM 宿主 ABI 符号(如 `procedure_start_mut_tx`、`datastore_insert_bsatn`、`console_log`)没有原生宿主提供,导致测试体执行前就 `LNK2019` / `LNK1120`。 +- 决策:在 `server-rs/crates/spacetime-module/src/test_stubs.rs` 中用 `#[cfg(all(test, not(target_arch = "wasm32")))]` 提供 25 个 test-only ABI 桩,只满足原生测试链接;`console_log` 静默 no-op,其余桩只用于暴露误调用。该桩不进入 WASM 构建,也不允许把 native 单测误当成 SpacetimeDB runtime 行为测试。 +- 影响范围:`server-rs/crates/spacetime-module/src/lib.rs`、`server-rs/crates/spacetime-module/src/test_stubs.rs`、P2 计划文档和 `.hermes/shared-memory/pitfalls.md`。 +- 验证方式:`cargo test -p spacetime-module runtime_job --manifest-path server-rs/Cargo.toml` 通过 6 个纯逻辑用例;`cargo check -p spacetime-module --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml` 通过。 +- 关联文档:`docs/planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md`、`.hermes/shared-memory/pitfalls.md`。 + +## 2026-06-17 Editor Agent P2 runtime job 取消优先于 worker 结果 + +- 背景:P2-08 开始支持用户取消 queued / running Web Project runtime job;如果 worker 已经跑到 runner 末尾,仍可能在用户取消后继续 complete / fail,并生成 preview URL 或把 linked preview build 写成 failed。 +- 决策:取消请求是 runtime job 终态写回的最高优先级。`spacetime-module` 的 `complete_web_project_runtime_job`、`fail_web_project_runtime_job` 和 `complete_web_project_preview_build_runtime_job` 都必须在同一 transaction 内先检查 `cancel_requested_at`,命中后写 `cancelled`、清理 worker lease、禁止生成 artifact preview token / URL、禁止推进 `active_preview_build_id`。Queued cancel 由 HTTP cancel handler 同步把 linked preview build 写成 `cancelled`;running cancel 由 worker heartbeat / 最终续租检测后收敛,当前 TempDir runner 暂不承诺强杀子进程。 +- 影响范围:`server-rs/crates/spacetime-module/src/web_project.rs`、`server-rs/crates/api-server/src/web_project_runtime_worker.rs`、`server-rs/crates/api-server/src/web_project_preview_runtime.rs`、`server-rs/crates/api-server/src/web_project.rs`、`src/services/web-project/webProjectClient.ts`、`src/components/editor/agent/WebProjectAgentEditorPage.tsx`。 +- 验证方式:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p spacetime-module runtime_job --manifest-path server-rs/Cargo.toml`、`npm run test -- src/services/web-project src/components/editor/agent --reporter verbose`、`npm run typecheck`。 +- 关联文档:`docs/planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md`。 + +## 2026-06-17 Editor Agent P3 先采用远程同容器 Agent MVP + +- 背景:P2 完成后,`/editor/agent` 已具备 runtime job、worker / lease、日志恢复、取消 / stale 和 `SandboxRuntime` 抽象;为了加快真实 Agent 和远程 Container 落地,需要在不推翻 P1 / P2 控制面的前提下先跑通 MVP。 +- 决策:P2 后新增长期架构文档,明确真实 Agent、用户工作区、Agent 私有 shell、ShellPolicy / CommandGuard、ToolBridge、LLM Broker 与后续容器拆分边界。P3 采用 Remote Agent Server + Remote Container 执行面。第一版允许真实 Agent 与用户 workspace 位于同一个 Remote Container,Agent 可以使用完整 shell,但用户不开放 shell;所有 shell 命令必须经过 `ShellPolicy` / `CommandGuard` 接口,MVP 可先 `AllowAllShellPolicy`,后续再补正式 allowlist / denylist / 审批规则。Agent 调 LLM 通过 `LLM Broker`,Container 不持有长期 provider key。Container 内修改只能收集 diff 转 `WebProjectPatch`,由 api-server 校验后保存 snapshot;前端不直连 Container,preview 继续走 Preview Gateway + iframe sandbox。 +- 影响范围:`/editor/agent` P3 计划、Remote Agent Server、runtime job kind、ShellPolicy / ToolBridge / SandboxRuntime 抽象、LLM Broker、后续 Agent / Workspace 双容器拆分。 +- 验证方式:P3 MVP 必须覆盖真实 Agent 处理用户需求、Remote Container hydrate snapshot、Agent shell 经 ShellPolicy 审计、patch 校验落 snapshot、自动触发 preview build、刷新恢复 agent job / 日志 / active preview、失败不覆盖上一版 preview。 +- 关联文档:`docs/technical/【技术方案】EditorAgent远程Agent与工作区沙箱架构-2026-06-17.md`、`docs/planning/【开发计划】EditorAgentP3远程AgentContainerMVP计划-2026-06-17.md`、`docs/technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 06270869..9a86ccd1 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1324,13 +1324,13 @@ - 验证:查看 `server-rs/Cargo.toml` default-members,并按相关 SpacetimeDB 文档执行模块构建。 - 关联:`server-rs/Cargo.toml`、`docs/technical/RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md`。 -## Windows 原生 `spacetime-module` 单测会链接缺失 SpacetimeDB 宿主符号 +## Windows 原生 `spacetime-module` 纯逻辑单测需要 test-only ABI 桩 -- 现象:在 Windows 上执行 `cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml` 可能编译到链接阶段后失败,出现 `LNK2019` / `LNK1120`,缺失 `datastore_insert_bsatn`、`procedure_start_mut_tx`、`console_log` 等 SpacetimeDB 宿主符号。 -- 原因:`spacetime-module` 依赖的 SpacetimeDB runtime API 面向 wasm 宿主环境,原生 test exe 链接不到这些宿主导出。 -- 处理:日常语法和类型验证使用 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`;需要验证模块行为时走 SpacetimeDB publish/dev 或模块域纯 Rust crate 的单测,不把该原生链接错误当作业务测试失败。 -- 验证:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 能通过;原生 `cargo test` 若仍报上述宿主符号缺失,按当前限制记录为未执行。 -- 关联:`server-rs/crates/spacetime-module`、`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`。 +- 现象:Windows native target 执行 `cargo test -p spacetime-module runtime_job --manifest-path server-rs/Cargo.toml` 曾在链接阶段失败,报 `LNK2019` / `LNK1120`,缺失 `datastore_insert_bsatn`、`procedure_start_mut_tx`、`console_log` 等 SpacetimeDB 宿主符号;这些 runtime job 用例本身只测纯 Rust helper,并不调用 SpacetimeDB 表或 procedure 宿主 ABI。 +- 原因:`spacetime-module` 的正常发布目标是 SpacetimeDB WASM module,`spacetimedb-bindings-sys` 声明的宿主 ABI 由 SpacetimeDB WASM 运行时注入;Windows native `cargo test` 会把整个 crate 链成原生测试 exe,即使测试体不触碰宿主 ABI,链接器也需要这些符号。 +- 处理:`server-rs/crates/spacetime-module/src/test_stubs.rs` 在 `#[cfg(all(test, not(target_arch = "wasm32")))]` 下为本次缺失的 25 个 ABI 符号提供桩实现,`console_log` 静默 no-op,其余桩用于暴露误调用。该方案只允许跑不访问 `ctx.db`、表扫描 / 写入、procedure transaction、JWT、identity、bytes source/sink 等宿主能力的纯逻辑单测;真实 SpacetimeDB 行为仍必须走 wasm target check、bindings 生成、schema guard、publish/dev 或端到端 smoke。 +- 验证:`cargo test -p spacetime-module runtime_job --manifest-path server-rs/Cargo.toml` 通过 6 个纯逻辑用例;`cargo check -p spacetime-module --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml` 通过,确认 test-only 桩不影响 WASM module 构建。 +- 关联:`server-rs/crates/spacetime-module/src/test_stubs.rs`、`server-rs/crates/spacetime-module/src/lib.rs`、`docs/planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md`。 ## Rust 构建不要让不可用的 sccache 阻断 rustc @@ -2262,3 +2262,11 @@ - 处理:preview gateway 服务 artifact 的成功响应统一带 `Access-Control-Allow-Origin: *`,但仍保留独立 origin、`sandbox="allow-scripts"` 和 CSP `connect-src 'none'`,不要为了修加载问题放宽 iframe sandbox。 - 验证:`cargo test -p api-server web_project --manifest-path server-rs/Cargo.toml`,并用浏览器 smoke 确认 iframe 中计数按钮从 `已点击 0 次` 变为 `已点击 1 次`。 - 关联:`server-rs/crates/api-server/src/web_project_preview_gateway.rs`、`docs/technical/【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md`。 + +## Editor Agent P2 smoke 不能从裸 dev:api-server 开始 + +- 现象:运行 `npm run dev:api-server` 后,api-server 在启动阶段请求 SpacetimeDB `/v1/identity` 失败;或者完整 dev ready 后提交 Web 工程构建一直 queued;或者单独启动 `GENARRATIVE_PROCESS_ROLE=web-project-runtime-worker` 时订阅 / procedure 调用 401。 +- 原因:`dev:api-server` 只启动 Rust API 进程,不会自动启动 standalone,也不会发布当前 `spacetime-module` 或引导 Web Project service identity;P2 runtime job 还必须有 `web-project-runtime-worker` 消费。完整 `dev` 会先启动 SpacetimeDB 并 publish module,Windows native 冷编译 / 首次 publish 可能超过 5 分钟;收口后完整 `npm run dev` 会为 api-server 注入 `GENARRATIVE_PROCESS_ROLE=all`。拆分终端手动 worker 若没有复用本轮 dev 脚本创建的 `GENARRATIVE_SPACETIME_TOKEN`,会以错误 identity 连接并 401。 +- 处理:`/editor/agent` P2 端到端 smoke 优先运行 `npm run dev -- --no-interactive --api-timeout-seconds 900`,等待完整栈 ready 后再执行 `npm run check:editor-agent-p2-smoke` 和真实浏览器步骤。若拆分终端,必须先 `npm run dev:spacetime` 等待发布完成,再启动 `npm run dev:api-server` 和 `npm run dev:web`;手动 worker 必须显式带同一个 `GENARRATIVE_SPACETIME_SERVER_URL`、`GENARRATIVE_SPACETIME_DATABASE` 和 `GENARRATIVE_SPACETIME_TOKEN`。 +- 验证:`.app/dev-stack.json` 里的 `spacetime`、`api-server`、`web` 和 `web-project-preview` URL 与实际 HTTP 探测一致;`npm run check:editor-agent-p2-smoke` 通过后,再在浏览器完成计数按钮、破坏构建、连续提交、取消和刷新恢复 smoke。浏览器自动化无法进入 `sandbox="allow-scripts"` 的 preview iframe 时,直接打开后端签发的 preview URL 验证交互。 +- 关联:`scripts/dev.mjs`、`scripts/check-editor-agent-p2-smoke.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md`。 diff --git a/docs/README.md b/docs/README.md index c01096fd..dcb5dee9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,7 +21,7 @@ 微信小程序虚拟支付接入、`wechat_mp_virtual` 渠道、`wx.requestVirtualPayment` 承接页和后端签名配置见 [【技术方案】微信虚拟支付接入-2026-05-26.md](./%E3%80%90%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88%E3%80%91%E5%BE%AE%E4%BF%A1%E8%99%9A%E6%8B%9F%E6%94%AF%E4%BB%98%E6%8E%A5%E5%85%A5-2026-05-26.md)。 -`/editor/agent` 浏览器内 AI Web 工程编辑器的静态 SPA 沙箱预览 MVP,采用“平台编辑器壳 + api-server 控制面 + 独立 runner worker + 独立预览域”四层结构;技术方案、威胁模型和验收清单见 [【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md](./technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md)、[【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md](./technical/【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md) 和 [【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md](./technical/【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md)。P1 先用确定性 mock Agent 生成结构化 patch、真实打通项目 / 快照 / 构建 / artifact / 预览闭环,落地拆分见 [【技术方案】EditorAgentMockAgentP1落地计划-2026-06-15.md](./technical/【技术方案】EditorAgentMockAgentP1落地计划-2026-06-15.md),可执行开发计划见 [【开发计划】EditorAgentMockAgentP1可执行开发计划-2026-06-15.md](./planning/【开发计划】EditorAgentMockAgentP1可执行开发计划-2026-06-15.md)。P2 先补持久 runtime job、worker / lease、日志恢复和 SandboxRuntime 抽象,计划见 [【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md](./planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md)。 +`/editor/agent` 浏览器内 AI Web 工程编辑器的静态 SPA 沙箱预览 MVP,采用“平台编辑器壳 + api-server 控制面 + 独立 runner worker + 独立预览域”四层结构;技术方案、威胁模型和验收清单见 [【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md](./technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md)、[【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md](./technical/【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md) 和 [【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md](./technical/【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md)。P1 先用确定性 mock Agent 生成结构化 patch、真实打通项目 / 快照 / 构建 / artifact / 预览闭环,落地拆分见 [【技术方案】EditorAgentMockAgentP1落地计划-2026-06-15.md](./technical/【技术方案】EditorAgentMockAgentP1落地计划-2026-06-15.md),可执行开发计划见 [【开发计划】EditorAgentMockAgentP1可执行开发计划-2026-06-15.md](./planning/【开发计划】EditorAgentMockAgentP1可执行开发计划-2026-06-15.md)。P2 先补持久 runtime job、worker / lease、日志恢复和 SandboxRuntime 抽象,计划见 [【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md](./planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md)。P2 后真实 Agent、用户工作区、Agent 私有 shell、ShellPolicy / ToolBridge、LLM Broker 和后续容器拆分的长期架构见 [【技术方案】EditorAgent远程Agent与工作区沙箱架构-2026-06-17.md](./technical/【技术方案】EditorAgent远程Agent与工作区沙箱架构-2026-06-17.md);P3 采用 Agent + workspace 同 Remote Container 的 MVP 方案,计划见 [【开发计划】EditorAgentP3远程AgentContainerMVP计划-2026-06-17.md](./planning/【开发计划】EditorAgentP3远程AgentContainerMVP计划-2026-06-17.md)。 `/editor/canvas` 图片画布编辑器的画布素材 ZIP 导出能力,入口放在右上角标题栏下载图标内,第一版采用前端 JSZip 打包画布中有效图层引用的上传图、生成图和修改结果,方案见 [【前端架构】图片画布素材导出方案-2026-06-15.md](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)。 diff --git a/docs/planning/README.md b/docs/planning/README.md index 49cf0d9c..5f7c0d30 100644 --- a/docs/planning/README.md +++ b/docs/planning/README.md @@ -7,6 +7,7 @@ - [【玩法创作】创作流程统一总计划-2026-05-30.md](./【玩法创作】创作流程统一总计划-2026-05-30.md):创作入口、统一创作页、统一生成页、结果页、发布、作品架、广场和运行态的阶段计划、进度记录、并行波次和可直接派发的任务包。 - [【开发计划】EditorAgentMockAgentP1可执行开发计划-2026-06-15.md](./【开发计划】EditorAgentMockAgentP1可执行开发计划-2026-06-15.md):`/editor/agent` Mock Agent P1 的可执行波次、任务包、验收门禁和覆盖矩阵,完整承接技术落地计划。 - [【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md](./【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md):`/editor/agent` P2 的持久 runtime job、worker / lease、日志恢复和 SandboxRuntime 抽象计划。 +- [【开发计划】EditorAgentP3远程AgentContainerMVP计划-2026-06-17.md](./【开发计划】EditorAgentP3远程AgentContainerMVP计划-2026-06-17.md):`/editor/agent` P2 后加速远程 Container 与真实 Agent 的 MVP 计划,第一版采用 Agent + workspace 同 Remote Container,保留 ShellPolicy / ToolBridge / SandboxRuntime 接口以便后续拆分隔离。 ## 维护规则 diff --git a/docs/planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md b/docs/planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md index ebc62080..956ea1be 100644 --- a/docs/planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md +++ b/docs/planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md @@ -92,6 +92,541 @@ sandbox = 某个 snapshot 展开后的隔离执行环境 | P2e | 日志和恢复 | 日志分页、SSE 重连、刷新恢复、取消和 stale 验收 | | P2f | 文档与门禁 | 更新架构文档、验收清单和 smoke 流程 | +## 可执行落地步骤 + +P2 落地按下面步骤推进。每一步都必须能独立验证,不把“建表、worker、runner、前端恢复、SandboxRuntime”混在同一个大提交里。 + +### P2-00:基线确认与任务入口冻结 + +目标: + +- 确认 P1 的 project、snapshot、preview build、runner、artifact、preview gateway 和前端入口仍可用。 +- 冻结 P2 不扩权:不接真实 Shell、不开放任意 npm 依赖、不把 Docker workspace 当事实源。 + +改动范围: + +- 只允许更新 P2 计划、P1 交接说明和验收清单。 +- 不改 schema,不改运行时代码。 + +最小验证: + +```bash +cargo test -p api-server web_project --manifest-path server-rs/Cargo.toml +cargo test -p web-project-runner --manifest-path server-rs/Cargo.toml +npm run test -- src/services/web-project src/components/editor/agent --reporter verbose +npm run check:encoding +``` + +进入下一步门槛: + +- P1 happy path 和“破坏构建保留上一版预览”测试仍通过。 +- 文档明确 P2 只做任务生命周期和 SandboxRuntime 抽象。 + +落地记录: + +- 状态:已完成,时间 `2026-06-16`。 +- 已确认本步只做基线验证和文档冻结,不改 schema、不改运行时代码、不进入 worker / SandboxRuntime 实现。 +- `cargo test -p api-server web_project --manifest-path server-rs/Cargo.toml` 通过:5 个用例通过;存在 3 个既有 `dead_code` warning,不阻断 P2-00。 +- `cargo test -p web-project-runner --manifest-path server-rs/Cargo.toml` 通过:10 个用例通过。 +- `npm run test -- src/services/web-project src/components/editor/agent --reporter verbose` 通过:4 个测试文件、18 个用例通过;本机初次运行缺少 `node_modules/.bin/vitest`,已执行 `npm install` 恢复本地依赖后重跑通过。 +- `npm run check:encoding` 通过。 + +### P2-01:Runtime Job Schema 与迁移落点 + +目标: + +- 在 SpacetimeDB 中新增 `web_project_runtime_job` 和 `web_project_runtime_job_log`。 +- 字段、索引、默认值、表目录、`migration.rs` 和生成绑定一次性对齐。 + +改动范围: + +- `server-rs/crates/spacetime-module` 表结构、迁移和表目录。 +- `server-rs/crates/spacetime-client` / generated bindings 只做生成结果承接。 +- 不接 api-server 路由,不启动 worker。 + +最小验证: + +```bash +npm run spacetime:generate +npm run check:spacetime-schema +cargo test -p spacetime-module web_project --manifest-path server-rs/Cargo.toml +npm run check:encoding +``` + +进入下一步门槛: + +- 新表通过 schema guard。 +- 新增字段若落在已有表,必须位于结构体末尾并带明确默认值;优先避免改已有表。 + +落地记录: + +- 状态:已完成,时间 `2026-06-16`。 +- 已新增 `web_project_runtime_job` 与 `web_project_runtime_job_log` 两张 SpacetimeDB 表,未修改既有 `web_project_preview_build` 字段,避免引入已有表迁移风险。 +- 已同步 `server-rs/crates/spacetime-module/src/migration.rs`,两张 runtime job 表随业务迁移包导入导出;`web_project_service_identity` 继续作为环境级服务授权表排除在迁移数据外。 +- 已更新表目录,明确 runtime job 只描述执行生命周期,不替代 `web_project_snapshot` 作为工程文件事实源。 +- 已执行 `npm run spacetime:generate` 并生成 Rust bindings;首次运行因编译超时被工具截断,第二次长超时运行完成。SpacetimeDB CLI 自带格式化在 Windows 长路径下报 `os error 206`,脚本已按短路径分批 rustfmt 兜底并成功退出。 +- `npm run check:spacetime-schema` 通过。 +- `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 通过。 +- `npm run check:encoding` 通过。 +- 2026-06-17 复核后补齐原生测试口径:`spacetime-module` 已通过 `#[cfg(all(test, not(target_arch = "wasm32")))]` 引入 test-only SpacetimeDB ABI 桩,解决 Windows native `cargo test` 链接阶段缺少宿主符号的问题。`cargo test -p spacetime-module runtime_job --manifest-path server-rs/Cargo.toml` 已可执行到测试体并通过:6 个 runtime job 纯逻辑用例通过。该桩只服务不访问 SpacetimeDB 宿主 ABI 的 helper 单测,真实 module 行为仍以 wasm target check、bindings 生成、schema guard 和 SpacetimeDB publish/dev 验证为准。 + +### P2-02:Runtime Job 状态机 Reducer / Procedure + +目标: + +- 实现 create、claim、renew、complete、fail、cancel、mark stale / expired、append log 的原子流转。 +- 所有 owner 校验、snapshot 校验和 lease 校验都在 SpacetimeDB transaction 内完成。 + +改动范围: + +- `spacetime-module` 的 Web Project runtime job 领域逻辑。 +- 不调用 runner,不写 artifact,不改前端。 + +最小验证: + +```bash +cargo test -p spacetime-module runtime_job --manifest-path server-rs/Cargo.toml +npm run check:spacetime-schema +npm run check:encoding +``` + +最小用例: + +- `queued -> running -> succeeded` 正常流转。 +- 错误 `lease_token` 不能 complete / fail。 +- lease 过期后可重新 claim,`attempt` 递增。 +- `failed / cancelled / expired / stale` 不允许再推进 active preview。 + +进入下一步门槛: + +- 状态机测试覆盖所有终态。 +- 旧 snapshot job 的成功回写在模块层被拒绝或降级为 stale。 + +落地记录: + +- 状态:已完成,时间 `2026-06-16`。 +- 已在 `server-rs/crates/spacetime-module/src/web_project.rs` 按现有 Web Project `procedure + try_with_tx + service identity` 风格新增 runtime job 状态机 procedure。 +- 已覆盖 `create / get / claim / renew / complete / fail / cancel / stale / expire / append log` 的事务内流转;所有入口先校验 Web Project service identity,再在事务内校验 owner、project、snapshot、preview build 关联和 worker lease。 +- `claim` 只领取 `queued` 或 lease 已过期的 `running`,并写入 `worker_id`、`lease_token`、`lease_expires_at`、`started_at` 和递增后的 `attempt`。 +- `renew / complete / fail / append log` 在提供 worker lease 时必须匹配当前 `worker_id + lease_token`,且 lease 未过期。 +- `complete` 在 job 的 `snapshot_id` 不等于项目 `active_snapshot_id` 时不推进成功,直接将 job 降级为 `stale`;新 snapshot 保存时也会将同项目未终态 runtime job 标记为 `stale`。 +- `queued` 取消直接进入 `cancelled`;`running` 取消先记录 `cancel_requested_at`,等待后续 worker 阶段收敛。 +- `append log` 持久化到 `web_project_runtime_job_log`,按 `job_id + sequence` 做重复 sequence 防护,日志 level 和 message 做长度裁剪。 +- 已重新执行 `npm run spacetime:generate`,生成 runtime job 输入、快照、procedure result 和 procedure caller 绑定。 +- `npm run check:spacetime-schema` 通过。 +- `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 通过。 +- `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml` 通过。 +- `npm run check:encoding` 通过。 +- `git diff --check` 通过。 +- 2026-06-17 已补齐 `server-rs/crates/spacetime-module/src/test_stubs.rs`,为 Windows native 测试链接阶段提供 25 个 SpacetimeDB WASM 宿主 ABI 符号桩;`console_log` 静默 no-op,其余桩仅用于暴露误调用。`cargo test -p spacetime-module runtime_job --manifest-path server-rs/Cargo.toml` 已通过:6 个测试通过。边界:这些用例只覆盖 runtime job helper 纯逻辑,不验证 `ctx.db`、表扫描 / 写入、procedure transaction 或真实 SpacetimeDB runtime。 + +### P2-03:Spacetime Client Facade 与 API 契约 + +目标: + +- 在 `spacetime-client` 封装 runtime job facade,api-server 不直接拼 reducer / procedure 细节。 +- 定义 HTTP DTO:创建 job、查询 job、分页日志、取消 job、未终态 job 列表。 + +改动范围: + +- `spacetime-client` Web Project facade。 +- `shared-contracts` / `packages/shared` 中必要 DTO。 +- `api-server` 可以加 handler skeleton,但不触发 runner。 + +最小验证: + +```bash +cargo test -p spacetime-client web_project --manifest-path server-rs/Cargo.toml +cargo test -p api-server web_project_runtime_job --manifest-path server-rs/Cargo.toml +npm run typecheck +npm run check:encoding +``` + +进入下一步门槛: + +- API handler 能创建 queued job、读取状态和分页日志。 +- API 不直接修改 SpacetimeDB 表,只经 facade。 + +落地记录: + +- 状态:已完成,时间 `2026-06-17`。 +- 已在 `spacetime-module` 追加两个窄读 procedure:`list_open_web_project_runtime_jobs_and_return` 与 `list_web_project_runtime_job_logs_and_return`,用于 P2-03 API 的未终态 job 列表和日志分页;未修改 runtime job 表结构。 +- 已重新执行 `npm run spacetime:generate`,生成 `WebProjectRuntimeJobListOpenInput`、`WebProjectRuntimeJobListLogsInput` 和对应 procedure caller bindings。Windows 本机本次 release 生成约 9 分钟,`wasm-opt` 缺失与 SpacetimeDB CLI 长路径格式化失败均由脚本兜底处理,脚本最终成功退出。 +- 已在 `server-rs/crates/spacetime-client/src/web_project.rs` 封装 runtime job facade:`create / get / list open / claim / renew / complete / fail / cancel / stale / expire / append log / list logs`,api-server 不直接调用 generated procedure。 +- 已在 `shared-contracts` 与 `packages/shared` 定义 `WebProjectRuntimeJob`、`WebProjectRuntimeJobLog`、创建请求、单条响应、未终态列表响应和日志分页响应;HTTP DTO 不下发 worker `leaseToken`。 +- 已在 `api-server` 增加最小 runtime job skeleton: + - `POST /api/runtime/web-project/projects/{project_id}/runtime-jobs` + - `GET /api/runtime/web-project/projects/{project_id}/runtime-jobs` + - `GET /api/runtime/web-project/runtime-jobs/{job_id}` + - `POST /api/runtime/web-project/runtime-jobs/{job_id}/cancel` + - `GET /api/runtime/web-project/runtime-jobs/{job_id}/logs` +- 本步未改现有 `POST /api/runtime/web-project/projects/{projectId}/preview-builds` 行为,仍不触发 worker、不迁移 runner、不改前端主流程;这些留到 P2-04 之后。 +- `cargo check -p spacetime-module --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml` 通过。 +- `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml` 通过。 +- `cargo check -p api-server --manifest-path server-rs/Cargo.toml` 通过;仍存在 3 个既有 puzzle `dead_code` warning。 +- `cargo test -p spacetime-client web_project --manifest-path server-rs/Cargo.toml` 通过:2 个相关用例通过。 +- `cargo test -p api-server web_project_runtime_job --manifest-path server-rs/Cargo.toml` 通过:2 个相关用例通过;仍存在 3 个既有 puzzle `dead_code` warning。 +- `npm run typecheck` 通过。 +- `npm run check:spacetime-schema` 通过。 +- `npm run check:encoding` 通过。 +- `git diff --check` 通过。 + +### P2-04:Preview Build 入队化 + +目标: + +- 保持现有 `POST /api/runtime/web-project/projects/{projectId}/preview-builds` 外部入口。 +- 将 preview build 从请求内同步触发 runner 改为创建 `web_project_runtime_job(kind=preview_build)`。 +- 返回值带 `jobId` 和当前 build 状态,前端仍不拼 preview URL。 + +改动范围: + +- `api-server` preview build handler。 +- 前端 service 类型只承接 `jobId`,不改 UI 主流程。 +- runner 仍可暂时不接 worker。 + +最小验证: + +```bash +cargo test -p api-server web_project_preview_build --manifest-path server-rs/Cargo.toml +npm run test -- src/services/web-project --reporter verbose +npm run typecheck +npm run check:encoding +``` + +最小用例: + +- 创建 preview build 后能查到 queued runtime job。 +- 重复创建 build 不破坏 active preview。 +- API 仍兼容 P1 前端调用路径。 + +进入下一步门槛: + +- 没有 worker 时,前端能看到“已入队 / 构建中”的后端状态,不假装成功。 + +落地记录(2026-06-17): + +- 已将 `POST /api/runtime/web-project/projects/{projectId}/preview-builds` 从请求内 runner 执行改为“创建 preview build + 创建 `web_project_runtime_job(kind=preview_build)`”。 +- 该入口不再抢占 api-server 进程内 preview build slot,也不再调用 `spawn_preview_build_task`;旧 runner helper 保留为 P2-05 worker 接入时复用的过渡代码。 +- `WebProjectPreviewBuildResponse` 保留 `build` 字段,并新增可选 `runtimeJob` 字段;P1 前端继续只消费 `build`,不拼接 preview URL。 +- 若 runtime job 入队失败,api-server 会尽力把刚创建的 preview build 标记为 `failed` 并推送失败 SSE,避免留下没有 worker 可消费的永久 queued build。 +- 本步不推进 `web_project.active_preview_build_id`,active preview 仍只应在后续 worker 成功写回 artifact / preview token 后推进。 + +已验证: + +```bash +cargo check -p api-server --manifest-path server-rs/Cargo.toml +npm run test -- src/services/web-project --reporter verbose +npm run test -- src/components/editor/agent/WebProjectAgentEditorPage.test.tsx --reporter verbose +npm run typecheck +git diff --check +``` + +备注:`cargo check -p api-server` 仍有 3 个既有 puzzle `dead_code` warning,和 P2-04 无关。 + +### P2-05:Runtime Worker 骨架与 Lease Loop + +目标: + +- 新增 Web Project runtime worker 角色,能 claim job、写 running 日志、续租、无构建 dry-run 完成或失败。 +- worker 进程身份和 `worker_id` 可观测。 + +改动范围: + +- `api-server` 或独立 crate 中新增 worker loop,按现有工程进程组织方式接入。 +- 不复用 `external_generation_job`。 +- 不接真实 runner。 + +最小验证: + +```bash +cargo test -p api-server web_project_runtime_worker --manifest-path server-rs/Cargo.toml +cargo check -p api-server --manifest-path server-rs/Cargo.toml +npm run check:encoding +``` + +最小用例: + +- worker 能 claim queued job。 +- renew 必须使用当前 `worker_id + lease_token`。 +- dry-run 成功 job 进入 `succeeded` 并产生日志。 +- dry-run 失败 job 进入 `failed` 并保留错误摘要。 + +落地记录(2026-06-17): + +- 已在 `api-server` 内新增 `web-project-runtime-worker` 进程角色,配置项为 `GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_ID`、`GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_CONCURRENCY`、`GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_POLL_INTERVAL_MS`、`GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_LEASE_SECONDS` 和 `GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_DRY_RUN_RESULT`。 +- worker 通过 `spacetime-client` facade 领取 `web_project_runtime_job`,按当前 `worker_id + lease_token` 追加持久日志、续租、完成或失败;`all` 角色会与 HTTP、external generation worker 同进程并行运行。 +- 默认 dry-run 结果为 `fail`,不会为 `preview_build` 伪造 artifact、preview token 或 preview URL;显式配置 `success` 时只允许无 preview build 绑定的 runtime job 完成,用于骨架验证。 +- dry-run 失败会先失败 runtime job,再 best-effort 将绑定的 `web_project_preview_build` 标记 failed 并广播 SSE。两次 procedure 存在部分成功风险;P2-06 或后续可考虑补组合 procedure。 + +进入下一步门槛: + +- worker crash 模拟后,lease 过期 job 可被新 worker 重领或被明确标记 `expired`。 + +### P2-06:TempDirBuildRuntime 接入 Runner + +目标: + +- 抽出第一版 `SandboxRuntime` / `TempDirBuildRuntime`,把 P1 runner 接到 worker。 +- worker hydrate snapshot,调用 runner,写 artifact、preview token 和 preview URL。 + +改动范围: + +- web-project runner 调用适配。 +- api-server worker 执行面。 +- 不引入 Docker,不开放 Shell。 + +最小验证: + +```bash +cargo test -p web-project-runner --manifest-path server-rs/Cargo.toml +cargo test -p api-server web_project_runtime_worker --manifest-path server-rs/Cargo.toml +cargo test -p api-server web_project --manifest-path server-rs/Cargo.toml +npm run check:encoding +``` + +最小用例: + +- “蓝色计数按钮页面” job 构建成功。 +- job 日志包含 hydrate、build、artifact、preview 四类关键阶段。 +- preview gateway 返回的 iframe 页面可加载。 + +落地记录(2026-06-17): + +- 已新增 `web_project_preview_runtime` 内部模块,形成第一版 `SandboxRuntime` trait 与 `TempDirBuildRuntime` 实现;当前实现继续通过受控子进程调用 `web-project-runner`,不引入 Docker,不开放 Shell。 +- `web-project-runtime-worker` 对绑定 `preview_build_id` 的 `preview_build` job 不再走 dry-run,而是追加 hydrate / build 日志、hydrate snapshot、调用 `TempDirBuildRuntime`,并在 runner 成功时写入 artifact、preview token 和 preview URL。 +- 成功路径先通过 `complete_web_project_runtime_job` 写 runtime job,并借用现有 `snapshot_id == active_snapshot_id` 校验;如果返回 `stale`,worker best-effort 将 linked preview build 标记 stale,不推进 preview build succeeded。 +- runner 失败或缺少 artifact 时,worker 将 preview build 标记 failed 并失败当前 runtime job;失败不会生成新的 preview token / URL,也不会覆盖上一版 active preview。 +- P2-06 仍保留 runtime job 与 preview build 两次 procedure 写回,worker 在 runner 结束后先做一次最终续租,再进入终态写回,降低 lease 过期导致的部分写回风险;彻底收口为单事务组合 procedure 或更强 reconcile 放到 P2-07。 + +进入下一步门槛: + +- runner 失败只让当前 job failed,不覆盖上一版 preview。 + +### P2-07:Active Preview Guard 与 Stale 收口 + +目标: + +- 成功 job 推进 active preview 前必须校验 `snapshot_id == project.active_snapshot_id`。 +- 新 snapshot 产生后,旧 snapshot 未终态 job 标记为 stale,或至少保证旧 job 不能推进 active preview。 + +改动范围: + +- SpacetimeDB 状态机和 api-server 成功回写路径。 +- 前端只显示 stale / failed / running 状态,不自行判断业务真相。 + +最小验证: + +```bash +cargo test -p spacetime-module runtime_job --manifest-path server-rs/Cargo.toml +cargo test -p api-server web_project_preview_build --manifest-path server-rs/Cargo.toml +npm run test -- src/services/web-project src/components/editor/agent --reporter verbose +npm run check:encoding +``` + +最小用例: + +- 连续提交两次,第二次 snapshot 成为 active。 +- 第一次 job 后完成时不能覆盖第二次 active preview。 +- stale job 的日志仍可读取。 + +进入下一步门槛: + +- “旧 job 成功覆盖新 preview”必须有自动测试拦截。 + +落地记录(2026-06-17): + +- 已新增 `complete_web_project_preview_build_runtime_job_and_return` 组合 procedure;runner 成功后的 runtime job 终态、linked preview build 终态和 `web_project.active_preview_build_id` 推进在同一个 SpacetimeDB transaction 中完成。 +- 组合 procedure 在写入 succeeded 前重新校验 `job.snapshot_id == web_project.active_snapshot_id`;若旧 job 在新 snapshot 成为 active 后才完成,则 runtime job 与 preview build 一起写成 `stale`,不写 preview token / URL,也不改 active preview 指针。 +- `spacetime-client` 已封装组合 facade,并新增 `WebProjectRuntimeJobPreviewBuildMutationRecord` 映射;`web-project-runtime-worker` 成功路径改为生成 finish plan 后调用组合 facade,不再执行“complete job 后 best-effort update preview build succeeded”的两步写回。 +- 前端不新增业务判断,继续只在后端返回 `build.status=succeeded && previewUrl` 时切换 iframe;已补 `stale` SSE / 页面用例锁住 stale 不覆盖旧 iframe。 +- 已重新执行 `npm run spacetime:generate`,生成组合 procedure input / result / caller bindings;Windows 长路径格式化失败由脚本短路径 rustfmt 兜底成功。 + +已验证: + +```bash +npm run spacetime:generate +cargo check -p spacetime-module --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml +cargo test -p spacetime-module runtime_job --manifest-path server-rs/Cargo.toml +cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml +cargo check -p api-server --manifest-path server-rs/Cargo.toml +cargo test -p spacetime-client web_project --manifest-path server-rs/Cargo.toml +cargo test -p api-server web_project_runtime_worker --manifest-path server-rs/Cargo.toml +npm run test -- src/services/web-project/webProjectSse.test.ts src/components/editor/agent/WebProjectAgentEditorPage.test.tsx --reporter verbose +``` + +备注:`cargo test -p spacetime-module runtime_job --manifest-path server-rs/Cargo.toml` 当前依赖 test-only ABI 桩,仅用于验证不触碰 SpacetimeDB 宿主 API 的 runtime job 纯逻辑;它不能替代 wasm target check、bindings 生成、schema guard 或 SpacetimeDB publish/dev 对真实 module 行为的验证。 + +### P2-08:取消、Expired 与 Crash 恢复 + +目标: + +- 支持用户取消 queued / running job。 +- running job crash 后通过 lease 过期重领,或进入确定 `expired`。 +- 取消请求可恢复、可查询、可展示。 + +实施进度(2026-06-17): + +- 已在 SpacetimeDB runtime job 终态写回中加入取消优先级:`complete / fail / complete preview build` 在同一事务内发现 `cancel_requested_at` 后一律写成 `cancelled`,清理 worker lease,不生成 preview token / URL,也不推进 `active_preview_build_id`。 +- 已补 worker loop 的取消检测:worker 每次 heartbeat / 最终续租都会读取续租后的 job,若发现取消请求则停止后续成功或失败写回,并把 linked preview build 收敛为 `cancelled`。 +- 已补 queued cancel 的 HTTP 同步收敛:未开始任务取消后,api-server 会同步把 linked preview build 标记为 `cancelled` 并广播现有 preview build SSE。 +- 已补前端最小取消入口:`/editor/agent` 创建 preview build 时保留 `runtimeJob`,queued / running job 显示“取消”按钮;刷新时读取 open runtime jobs 恢复未终态任务上下文。 +- 当前 TempDir runner 仍不是强杀式取消;running job 的取消语义是“请求已记录,worker 在 heartbeat 或 runner 返回后的最终续租处收敛”。后续 Docker / gVisor / microVM runtime 接入时再把强杀子进程 / 容器作为执行面能力补齐。 + +改动范围: + +- job cancel API:已存在,并补齐 queued linked preview build 的同步 `cancelled` 写回。 +- worker loop 的 cancel 检查:已完成 heartbeat / 最终续租检测。 +- 前端只加最小取消入口或状态展示,不做大 UI 重设计:已完成。 + +最小验证: + +```bash +cargo test -p spacetime-module runtime_job --manifest-path server-rs/Cargo.toml +cargo test -p api-server web_project_runtime_worker --manifest-path server-rs/Cargo.toml +npm run test -- src/services/web-project src/components/editor/agent --reporter verbose +npm run typecheck +npm run check:encoding +``` + +本轮已完成验证: + +```bash +cargo check -p api-server --manifest-path server-rs/Cargo.toml +cargo test -p spacetime-module runtime_job --manifest-path server-rs/Cargo.toml +npm run test -- src/services/web-project src/components/editor/agent --reporter verbose +npm run typecheck +``` + +最小用例: + +- queued job 取消后直接 `cancelled`。 +- running job 记录 `cancel_requested_at`,worker 收敛后 `cancelled`。 +- crash 模拟后 job 不永久停在 running:沿用既有 running lease 过期后可重领,以及 `expire_web_project_runtime_job` 的显式 expired procedure。 +- `cancelled / expired` 不覆盖 active preview。 + +进入下一步门槛: + +- 所有非成功终态都不影响上一版成功 preview。 + +### P2-09:日志分页、SSE 重连与刷新恢复 + +目标: + +- worker 日志持久化后,API 支持 `jobId + cursor/sequence` 分页读取。 +- SSE 断线后,前端先补缺失日志,再重新订阅。 +- 刷新 `/editor/agent?projectId=...` 后恢复 project、active snapshot、active preview、未终态 jobs 和日志。 + +改动范围: + +- API 日志分页和 SSE 事件源。 +- 前端 `sseStream.ts` 复用接入。 +- `/editor/agent` 恢复逻辑。 + +最小验证: + +```bash +cargo test -p api-server web_project_runtime_logs --manifest-path server-rs/Cargo.toml +npm run test -- src/services/web-project src/components/editor/agent --reporter verbose +npm run typecheck +npm run check:encoding +``` + +最小用例: + +- 日志分页按 sequence 递增。 +- SSE 断线重连后不重复、不丢日志。 +- 页面刷新后显示后端真实 job 状态和 active preview。 + +进入下一步门槛: + +- 前端没有用本地临时状态伪造 build 真相。 + +落地记录(2026-06-17): + +- 已复用既有 `GET /api/runtime/web-project/runtime-jobs/{jobId}/logs?afterSequence=&limit=` 日志分页接口,并补 `web_project_runtime_logs_paginates_by_sequence` 单元测试,锁住 API 层多取一条判断 `hasMore`、按 requested limit 截断和 `nextAfterSequence` 指向最后返回日志 sequence 的分页语义。 +- `/editor/agent` 新增 runtime job 持久日志恢复:页面创建 preview build、刷新恢复 open runtime job、取消后刷新状态、SSE 断线恢复时都会按 `afterSequence` 补齐缺失日志;日志 entry 使用 `jobId + sequence` 稳定去重,不依赖临时时间戳判断是否重复。 +- SSE 断线后前端先 best-effort 补齐 runtime job 日志,再刷新 preview build 状态;若 build 仍未终态,则短延迟后重新订阅,终态仍以后端 build payload / reload 结果为准。 +- 刷新 `/editor/agent?projectId=...` 时继续恢复 project、active snapshot、active preview 和未终态 preview runtime job;若存在 open runtime job,会先读取持久日志,再恢复对应 preview build 订阅。 +- P2 收口补齐 active preview build 刷新恢复:若项目已有 `activePreviewBuildId`,页面初始化会读取后端 build、写入 `currentBuild`、合并 build 日志,并只在非终态时恢复 SSE 订阅;已成功 build 刷新后直接显示“已完成”和后端 preview URL,不再残留旧的“排队中”状态。 + +已验证: + +```bash +cargo test -p api-server web_project_runtime_logs --manifest-path server-rs/Cargo.toml +npm run test -- src/services/web-project --reporter verbose +npm run test -- src/components/editor/agent/webProjectAgentViewModel.test.ts src/components/editor/agent/WebProjectAgentEditorPage.test.tsx --reporter verbose +npm run typecheck +``` + +备注:`cargo test -p api-server web_project_runtime_logs --manifest-path server-rs/Cargo.toml` 通过 1 个 P2-09 定向用例;仍存在既有 puzzle `dead_code` warning,和 P2-09 无关。 + +### P2-10:端到端 Smoke 与文档收口 + +目标: + +- 用真实浏览器完成 P2 happy path、失败保留预览、连续提交、刷新恢复和取消场景。 +- 更新架构文档、测试用例和开发运维说明。 + +改动范围: + +- `docs/technical`、`docs/planning`、必要的 `quality-gates`。 +- 自动 smoke 脚本或测试说明。 + +最小验证: + +```bash +npm run spacetime:generate +npm run check:spacetime-schema +cargo test -p spacetime-module web_project --manifest-path server-rs/Cargo.toml +cargo test -p spacetime-client web_project --manifest-path server-rs/Cargo.toml +cargo test -p api-server web_project --manifest-path server-rs/Cargo.toml +cargo test -p web-project-runner --manifest-path server-rs/Cargo.toml +npm run test -- src/services/web-project src/components/editor/agent --reporter verbose +npm run typecheck +npm run check:encoding +npm run check:editor-agent-p2-smoke -- --checklist-only +git diff --check +``` + +浏览器最小 smoke: + +```text +打开 /editor/agent +提交“做一个蓝色计数按钮页面” +等待 runtime job succeeded +确认 iframe 预览和按钮点击 +提交“破坏构建” +确认 failed job 不覆盖上一版 preview +连续提交两次并确认旧 job 不覆盖新 preview +取消一个未完成 job +刷新页面确认 active preview、job 状态和日志恢复 +``` + +落地记录(2026-06-17): + +- 已新增 `npm run check:editor-agent-p2-smoke`,用于读取 `.app/dev-stack.json` 并检查完整 dev 栈的 SpacetimeDB `/v1/ping`、api-server `/healthz`、主站 `/editor/agent` 和 preview gateway 无效 token guard;该脚本不启动长驻进程,不杀本机已有 SpacetimeDB,只作为 P2 浏览器 smoke 前置 ready 检查。 +- 已支持 `npm run check:editor-agent-p2-smoke -- --checklist-only` 输出真实浏览器最小 smoke 步骤,便于在完整 dev 栈启动成功后逐项确认 happy path、失败保留预览、连续提交 stale guard、取消和刷新恢复。 +- 本地验证口径已明确:`npm run dev:api-server` 不会自动启动或发布 SpacetimeDB,直接运行会在请求 `/v1/identity` 前提缺失时失败;P2 端到端 smoke 应使用 `npm run dev -- --no-interactive --api-timeout-seconds 900`,完整 dev 脚本会为 api-server 注入 `GENARRATIVE_PROCESS_ROLE=all`,同进程启动 Web Project runtime worker。若拆分终端,需要先 `npm run dev:spacetime`,再 `npm run dev:api-server` / `npm run dev:web`,并确保手动 worker 复用本轮 `GENARRATIVE_SPACETIME_TOKEN`。Windows 首次冷发布 `spacetime-module` 可能超过 5 分钟,需给 10-15 分钟窗口。 +- P2-10 仍要求真实浏览器手工确认 iframe 交互和刷新恢复;当前仓库未引入 Playwright 等浏览器自动化依赖,本步没有为了 smoke 增加新重依赖。 +- 2026-06-17 已在隔离端口 `19000 / 19082 / 19101 / 19102 / 19104` 尝试完整 `npm run dev -- --no-interactive --api-timeout-seconds 900`:SpacetimeDB standalone 启动、`spacetime-module` publish、Web Project service identity 自动授权和 `web-project-runner` 构建均已完成;api-server 随后因本机私有短信配置仍为 `SMS_AUTH_PROVIDER=aliyun` 且缺少 `ALIYUN_SMS_ACCESS_KEY_ID / SECRET`,在初始化阶段报 `初始化应用状态失败:阿里云短信 AccessKey 未配置` 并退出,未进入 `/healthz`、Vite 和真实浏览器 smoke。该阻断属于本机 env 前提,不是 P2 runtime job / worker / preview build 代码失败;继续真实浏览器 smoke 前需把本机 `.env.local` 或更后加载的 `.env.secrets.local` 显式设为 `SMS_AUTH_PROVIDER=mock`。 +- 用户补齐短信配置后,完整 dev 栈 ready 检查和真实浏览器 happy path 已通过:SpacetimeDB `/v1/ping`、api-server `/healthz`、主站 `/editor/agent`、preview gateway invalid token guard 均可访问;浏览器提交“做一个蓝色计数按钮页面”后 runtime job / build 进入 `succeeded`,直接打开 preview URL 可交互,按钮从“已点击 0 次”变为“已点击 1 次”。收口阶段发现并修复完整 dev 默认角色和刷新恢复两个残留点。 +- 当前不新增“按 active preview build 反查终态 runtime job”的后端接口;刷新恢复已覆盖 active build 状态和 build 日志,终态 runtime job 的完整持久日志回填等后续确有产品需要时再作为独立小步追加,避免 P2 收口继续扩大范围。 + +最终验收确认(2026-06-17): + +- 使用更新后的 `npm run dev -- --no-interactive --api-timeout-seconds 900 --web-port 19200 --api-port 19282 --spacetime-port 19301 --admin-web-port 19302 --web-project-preview-port 19304 --spacetime-data-dir .app/p2-final-dev-stack-smoke/spacetime-data --database genarrative-p2-final-smoke` 启动隔离完整栈;`npm run check:editor-agent-p2-smoke` 通过 SpacetimeDB、api-server、web 和 preview gateway ready 检查。 +- 自动化验收通过:`node --check scripts/dev.mjs`、`node --check scripts/check-editor-agent-p2-smoke.mjs`、`npm run test -- scripts/dev.test.ts src/services/web-project src/components/editor/agent --reporter verbose`、`npm run typecheck`、`npm run check:encoding`。 +- 真实浏览器 smoke 通过:登录本地 smoke 用户后打开 `/editor/agent`,提交“做一个蓝色计数按钮页面”,runtime worker 日志出现领取任务、hydrate、build、artifact、preview,build 进入 `succeeded`;直接打开 preview URL 后按钮从“已点击 0 次”变为“已点击 1 次”。 +- 失败与 stale guard 验收通过:提交“破坏构建”后新 build 进入 `failed`,iframe 仍保持上一版成功 preview URL;连续提交两次计数按钮改动后 active preview 更新到最新成功 preview URL,旧 job 没有覆盖当前 active preview。 +- 取消和刷新恢复验收通过:对新的 queued / running job 点击“取消”后状态收敛为 `cancelled`,上一版成功 preview URL 保持不变;刷新 `/editor/agent?projectid=...` 后 project、active snapshot、active preview、已完成状态和后端日志均从服务端恢复。 + +进入 P3 门槛: + +- P2 完成定义全部满足。 +- Docker sandbox、Shell、真实 coding agent、任意依赖安装和 HMR 仍保持未开放状态。 +- P3 的远程 Agent Container MVP 从 `agent_turn` job 开始推进,第一版允许 Agent + workspace 同 Remote Container,但用户 shell 不开放,Agent shell 必须经过 `ShellPolicy` 接口,详见 [【开发计划】EditorAgentP3远程AgentContainerMVP计划-2026-06-17.md](./【开发计划】EditorAgentP3远程AgentContainerMVP计划-2026-06-17.md)。 + ## P2a:持久任务表与状态机 新增 SpacetimeDB 表建议: @@ -227,6 +762,8 @@ SandboxRuntime - 用户取消时写 `cancel_requested_at` 并把未开始 job 标为 `cancelled`。 - running job 由 worker 检测取消请求后停止 runner 并回写 `cancelled`。 - P2 若暂不支持强杀子进程,也必须明确返回“取消请求已记录 / 等待 runner 收敛”的状态。 +- `complete / fail / complete preview build` 必须让 `cancel_requested_at` 优先于 worker 结果;即使 runner 已经成功产出 artifact,取消请求也不能生成新 preview URL 或推进 active preview。 +- linked preview build 的取消写回分两类:queued cancel 由 HTTP cancel handler 同步标记 `cancelled`;running cancel 由 worker 收敛时标记 `cancelled`,避免先把 running build 写成 failed 后再被终态保护拦住。 刷新恢复要求: diff --git a/docs/planning/【开发计划】EditorAgentP3远程AgentContainerMVP计划-2026-06-17.md b/docs/planning/【开发计划】EditorAgentP3远程AgentContainerMVP计划-2026-06-17.md new file mode 100644 index 00000000..bbc8a325 --- /dev/null +++ b/docs/planning/【开发计划】EditorAgentP3远程AgentContainerMVP计划-2026-06-17.md @@ -0,0 +1,431 @@ +# Editor Agent P3 远程 Agent Container MVP 计划 + +更新时间:`2026-06-17` + +## 计划定位 + +本文承接 `/editor/agent` P2。P2 完成后,`web_project_runtime_job`、worker / lease、日志分页、SSE 重连、取消 / stale 和 `SandboxRuntime` 抽象已经成为后续执行面的基础。 + +P3 的目标是尽快把真实 Agent 和远程 Container 跑通,但不推翻 P1 / P2 的控制面: + +```text +用户输入需求 + -> api-server 创建 agent_turn job + -> Remote Agent Server claim job + -> Remote Container 内运行真实 Agent 和工作区 shell + -> 收集 diff 转 WebProjectPatch + -> api-server 校验 patch 并保存 snapshot + -> 自动触发 preview_build job + -> Preview Gateway + iframe sandbox 展示结果 +``` + +一句话: + +```text +P3 先用同一个 Remote Container 快速跑通真实 Agent + 工作区 + shell,但接口按未来 Agent / Workspace 隔离形态设计。 +``` + +长期架构边界以 [../technical/【技术方案】EditorAgent远程Agent与工作区沙箱架构-2026-06-17.md](../technical/【技术方案】EditorAgent远程Agent与工作区沙箱架构-2026-06-17.md) 为准;本文只记录 P3 MVP 的实施顺序、验收和阶段边界。 + +## MVP 边界 + +P3 MVP 做: + +- 新增 `agent_turn` runtime job。 +- 新增 Remote Agent Server / worker 执行面,主动 claim job。 +- 第一版采用 `Agent + workspace` 同 Remote Container。 +- Agent 可以使用完整 shell,但 shell 必须经过 `ShellPolicy` / `CommandGuard` 接口。 +- MVP shell policy 可先是 `AllowAllShellPolicy`,规则待定但接口必须存在。 +- Agent 调 LLM 走平台 `LLM Broker`,Container 不持有长期 provider key。 +- Container 内文件变更必须收集 diff,转成 `WebProjectPatch` 后回传 api-server。 +- api-server 继续作为唯一 patch 校验和 snapshot 落库入口。 +- Agent 成功保存 snapshot 后自动创建 `preview_build` job。 + +P3 MVP 不做: + +- 用户可交互 shell。 +- Agent Container 与 Workspace Container 的强隔离拆分。 +- HMR / 长驻 dev server / WebSocket 端口代理。 +- 任意依赖安装的正式白名单治理。 +- Web Project 作品化发布。 +- Game SDK 强约束运行时。 +- Container 直接写 SpacetimeDB 或直接提升 workspace 为事实源。 + +## 总体架构 + +```text +前端 /editor/agent + -> api-server 控制面 + -> SpacetimeDB: project / snapshot / runtime job / logs / preview build + -> Remote Agent Server + -> agent-worker claim / renew / append log / complete / fail + -> Remote Container + -> Agent runtime + -> /workspace + -> shell tool + -> ShellPolicy / OutputFilter / AuditLogger + -> api-server 校验 WebProjectPatch 并保存 snapshot + -> preview_build job + -> Preview Gateway + -> iframe sandbox +``` + +### 前端边界 + +前端不直接连接 Container,不持有 Container token、SSH 信息或 Docker API。 + +前端只需要: + +- `POST /api/runtime/web-project/projects/{projectId}/agent-turns` 提交用户需求。 +- 通过 job SSE / 日志分页读取 `agent_turn` 状态和脱敏日志。 +- 读取 project / snapshot / preview build。 +- 使用后端返回的 `previewUrl` 加载 iframe。 + +用户不能调用 shell。shell 是 Agent 的内部工具,不暴露为 WebIDE UI 能力。 + +### api-server 控制面 + +api-server 不运行 Agent、不执行 shell、不启动本机用户代码。它负责: + +- 用户鉴权和 project owner 校验。 +- 创建 `agent_turn` job。 +- 下发短期任务能力给 Remote Agent Server。 +- 接收 Agent 产出的 patch、summary、日志摘要和结果状态。 +- 调用现有 patch validator。 +- 保存新 snapshot。 +- 创建 preview build。 +- 提供状态、日志、取消和刷新恢复接口。 + +api-server 不信任 Remote Container 的最终结论。构建成功、测试成功、Agent 成功都只能作为候选状态;正式工程文件事实以通过校验后的 snapshot 为准。 + +### SpacetimeDB 事实源 + +继续沿用 P2 的事实源口径: + +```text +web_project +web_project_snapshot +web_project_runtime_job +web_project_runtime_job_log +web_project_preview_build +``` + +`/workspace` 不是事实源。Remote Container 内的文件修改必须在任务结束或阶段性保存时转成 `WebProjectPatch`,再由 api-server 校验后生成新 snapshot。 + +## Remote Agent Server + +新增远程执行面,建议独立部署在专门服务器上,不放在 `api-server` 进程内。 + +职责: + +- 主动 claim `agent_turn` job。 +- 按 `worker_id + lease_token` renew lease。 +- 启动或复用 Remote Container。 +- hydrate 当前 snapshot 到 `/workspace`。 +- 调用真实 Agent。 +- 记录工具调用、shell 命令、输出摘要和错误。 +- 收集 diff,生成 `WebProjectPatch`。 +- 回传 patch 和 job 结果。 +- complete / fail / cancel job。 + +Remote Agent Server 不直接写 SpacetimeDB 表,只通过受控 facade / API 回写任务状态和结果。 + +## Remote Container MVP + +第一版为了速度采用同容器形态: + +```text +Remote Container + /workspace + /agent + /run/secrets/job_token +``` + +最低限制: + +- 非 root 用户运行。 +- 不挂载 Docker socket。 +- 不挂载当前 Genarrative 仓库源码。 +- 不注入平台 `.env`。 +- 不注入 SpacetimeDB token。 +- 不注入长期 LLM provider key。 +- 只挂载当前 job / project 的工作区。 +- 设置 CPU、内存、磁盘、进程数、打开文件数、任务时长和日志大小限制。 +- 网络默认最小化,只允许控制面回调、LLM Broker、受控 npm registry / asset gateway。 + +这不是最终隔离形态。P3 代码结构必须保留后续迁移到以下结构的接口: + +```text +Agent Container + -> Tool Bridge / sidecar + -> Workspace Container +``` + +## Agent 与 Shell + +Agent 可以使用完整 shell,但必须经过固定工具链: + +```text +Agent + -> ShellTool.run(command) + -> ShellPolicy.evaluate(command, context) + -> ContainerCommandExecutor.exec(command) + -> OutputFilter.redact(output) + -> AuditLogger.record(attempt/result) + -> Agent receives result +``` + +MVP policy: + +```text +AllowAllShellPolicy +``` + +即使规则暂时放空,也必须记录: + +- `jobId` +- `projectId` +- `snapshotId` +- `command` +- `workingDirectory` +- `decision` +- `policyVersion` +- `startedAt` +- `exitCode` +- 输出摘要 + +后续可在同一接口上补: + +- allowlist / denylist。 +- 禁止访问 `/run/secrets`。 +- 禁止后台进程。 +- 禁止危险文件名和隐藏配置。 +- 命令风险分级。 +- 需要人工批准的命令。 +- 网络策略联动。 + +## LLM Broker + +Agent Container 不直接持有 OpenAI / Claude 等 provider key。真实模型调用通过平台 `LLM Broker`: + +```text +Agent Container + -> short-lived job token + -> LLM Broker + -> provider +``` + +Broker 负责: + +- 校验 job token。 +- 校验 worker lease。 +- 校验 project / owner / job kind。 +- 模型路由。 +- 配额和速率限制。 +- 审计和日志脱敏。 +- provider key 管理。 + +## 接口与契约 + +P3 新增或扩展: + +```text +POST /api/runtime/web-project/projects/{projectId}/agent-turns +GET /api/runtime/web-project/agent-turns/{jobId} +GET /api/runtime/web-project/agent-turns/{jobId}/events +GET /api/runtime/web-project/runtime-jobs/{jobId}/logs +POST /api/runtime/web-project/runtime-jobs/{jobId}/cancel +``` + +建议新增 job kind: + +```text +agent_turn +preview_build +``` + +后续可扩展: + +```text +agent_fix_build +agent_shell_command +game_runtime_build +``` + +`agent_turn` 事件建议: + +- `queued` +- `running` +- `thinking` +- `tool_call_started` +- `tool_call_finished` +- `log` +- `patch_ready` +- `snapshot_saved` +- `preview_build_queued` +- `succeeded` +- `failed` +- `cancelled` +- `expired` +- `stale` + +前端展示以服务端事件和持久日志为准;SSE 断线后按 `jobId + afterSequence` 补日志,再重新订阅。 + +## 工具抽象 + +P3 虽然同容器实现,但接口上必须拆开: + +```text +AgentRuntime + runTurn(input) -> AgentTurnResult + +WorkspaceRuntime + hydrateSnapshot(snapshot) + collectDiff(baseSnapshot) + destroyWorkspace() + +ToolBridge + readFile(path) + writeFile(path, content) + runShell(command) + +ShellPolicy + evaluate(command, context) +``` + +同容器 MVP 可以让 `ToolBridge` 直接调用本地文件系统和本地 shell。后续分容器时,替换为 RPC sidecar,不改 api-server、job 状态机、snapshot / patch 主链路。 + +## 工作流 + +```text +1. 用户在 /editor/agent 输入需求 +2. 前端提交 agent_turn +3. api-server 创建 runtime job +4. Remote Agent Server claim job +5. Remote Container hydrate snapshot +6. Agent 调 LLM Broker +7. Agent 通过 ToolBridge 读写文件并调用 shell +8. ToolBridge 经 ShellPolicy 记录命令和输出 +9. 收集 diff 转 WebProjectPatch +10. api-server 校验 patch +11. 校验通过后保存新 snapshot +12. api-server 创建 preview_build job +13. preview worker 构建 artifact +14. Preview Gateway 签发 previewUrl +15. 前端 iframe sandbox 切换预览 +``` + +失败链路: + +```text +Agent 失败 / shell 超时 / Container 崩溃 + -> runtime job failed / expired + -> 已存在 active preview 保持不变 + -> 日志和错误摘要可恢复 +``` + +## 可执行落地步骤 + +### P3-00:文档与边界冻结 + +- 新增本文。 +- 更新总方案、文档索引和团队决策记录。 +- 冻结 P3 MVP 不做用户 shell、不做双容器强隔离、不做 HMR、不做作品化发布。 + +### P3-01:Job kind 与 HTTP 契约 + +- 在 shared contracts / packages shared 中新增 `agent_turn` job kind 和响应 DTO。 +- api-server 增加 `POST /agent-turns` skeleton。 +- 复用 P2 runtime job 状态机和日志分页。 + +### P3-02:Remote Agent Server skeleton + +- 新增远程 worker 进程或服务。 +- 支持 claim / renew / append log / fail。 +- 不跑真实 Agent,只 dry-run 失败或返回固定 patch。 + +### P3-03:Remote Container 执行 + +- worker 启动单 job container。 +- hydrate snapshot 到 `/workspace`。 +- 执行最小命令并收集日志。 +- 任务结束销毁 container 或清理 workspace。 + +### P3-04:ShellPolicy 接口 + +- 所有 shell 命令必须经过 `ShellPolicy`。 +- MVP 实现 `AllowAllShellPolicy`。 +- 接入 OutputFilter 和 AuditLogger。 + +### P3-05:真实 Agent 接入 + +- 接 LLM Broker。 +- Agent 读取需求、snapshot 摘要和构建上下文。 +- Agent 修改文件并可调用 shell。 +- 输出 diff / patch,而不是直接落库。 + +### P3-06:Patch 保存与预览联动 + +- Remote Agent Server 回传 `WebProjectPatch`。 +- api-server 校验并保存 snapshot。 +- 自动创建 preview build。 +- 前端展示 agent turn succeeded 和 preview build queued / running / succeeded。 + +### P3-07:构建失败修复闭环 + +- preview build failed 后,前端可触发修复。 +- Agent 基于脱敏构建日志生成修复 patch。 +- 修复仍走同一 patch 校验和 snapshot 保存链路。 + +## 验收场景 + +P3 MVP 最小验收: + +1. 打开 `/editor/agent`。 +2. 输入“做一个蓝色计数按钮小游戏页面”。 +3. api-server 创建 `agent_turn` job。 +4. Remote Agent Server claim job 并启动 Remote Container。 +5. Agent 调 LLM Broker。 +6. Agent 通过 ShellPolicy 调用至少一个 shell 命令。 +7. shell 命令记录 policy decision 和脱敏日志。 +8. Container 产出 diff。 +9. api-server 校验 patch 并保存 snapshot。 +10. 自动创建 preview build。 +11. preview build succeeded 后 iframe 展示新预览。 +12. 刷新页面后恢复 agent job、日志、snapshot 和 active preview。 +13. Agent / container 失败时上一版 active preview 不被覆盖。 + +## 风险与处理 + +| 风险 | 处理 | +| --- | --- | +| 同容器让用户代码影响 Agent | P3 接受该 MVP 风险,但 Agent / shell token 最小化,最终只通过 patch 落库;P4 拆 Agent / Workspace 容器 | +| 完整 shell 绕过后续治理 | P3 起所有 shell 都必须经过 ShellPolicy,即使当前 allow-all | +| Container 成为事实源 | 禁止直接落库,workspace diff 必须转 patch 并由 api-server 校验 | +| 前端直连 Container | 禁止,前端只连 api-server / gateway | +| LLM provider key 泄露 | provider key 只在 LLM Broker,Container 只拿短期 job token | +| 日志泄露 token 或内部路径 | OutputFilter 脱敏,用户可见日志摘要化 | +| 远程 worker 崩溃后 job 卡住 | 复用 P2 lease / expired / reclaim 机制 | + +## P4 预留 + +P3 跑通后再进入: + +- Agent Container + Workspace Container + Tool Bridge sidecar 拆分。 +- ShellPolicy 正式规则和人工批准。 +- 受控依赖安装。 +- 构建失败自动修复循环增强。 +- Game SDK 模板与后端正式裁决。 +- Web Project 作品化发布。 + +## 完成定义 + +P3 完成必须满足: + +- 真实 Agent 可在 Remote Container 内处理 `/editor/agent` 用户需求。 +- Agent shell 命令全部经过 ShellPolicy。 +- Container 变更通过 patch 校验保存为 snapshot。 +- 自动触发 preview build,并由 iframe sandbox 展示。 +- 前端不直连 Container,用户不能使用 shell。 +- Remote Agent Server 的日志、失败、取消和刷新恢复接入 P2 job 体系。 +- 后续拆成 Agent / Workspace 隔离容器时,不需要重写 api-server 控制面和 snapshot / patch 主链路。 diff --git a/docs/technical/【技术方案】EditorAgent远程Agent与工作区沙箱架构-2026-06-17.md b/docs/technical/【技术方案】EditorAgent远程Agent与工作区沙箱架构-2026-06-17.md new file mode 100644 index 00000000..79a126d6 --- /dev/null +++ b/docs/technical/【技术方案】EditorAgent远程Agent与工作区沙箱架构-2026-06-17.md @@ -0,0 +1,387 @@ +# Editor Agent 远程 Agent 与工作区沙箱架构 + +更新时间:`2026-06-17` + +## 背景 + +`/editor/agent` 已按 P1 / P2 跑通 Web Project 的 snapshot、patch、preview build、runtime job、worker / lease、日志恢复和独立 preview。下一步需要接入真实 Agent,并把用户工作区与 Agent 执行从 `api-server` 进程中移到专门服务器。 + +本文记录长期架构边界,不是单轮任务拆分。P3 的执行计划见 [../planning/【开发计划】EditorAgentP3远程AgentContainerMVP计划-2026-06-17.md](../planning/【开发计划】EditorAgentP3远程AgentContainerMVP计划-2026-06-17.md)。 + +## 结论 + +P2 后的 WebIDE + AI Agent 架构采用“控制面 / 执行面 / 预览面”分层: + +```text +前端 WebIDE /editor/agent + -> api-server 控制面 + -> SpacetimeDB 事实源 + -> Remote Agent Server 执行面 + -> Remote Container + -> Agent runtime + -> workspace + -> shell tool + -> ShellPolicy / ToolBridge + -> Preview Gateway 预览面 + -> iframe sandbox +``` + +第一版为了尽快落地,允许 `Agent runtime` 与用户 `workspace` 位于同一个 Remote Container;但接口必须按未来拆分为 `Agent Container + Workspace Container + ToolBridge sidecar` 的形态设计。 + +核心不变约束: + +- 前端不直连 Container。 +- api-server 不跑 Agent、不执行 shell、不运行用户代码。 +- 用户不能使用 shell。 +- Agent 可以使用 shell,但必须经过 `ShellPolicy` / `CommandGuard`。 +- Container 不直接写 SpacetimeDB。 +- workspace 不是事实源,snapshot 才是事实源。 +- 文件变更必须转成 `WebProjectPatch`,经 api-server 校验后保存 snapshot。 +- preview 只服务 immutable artifact,不服务 Container 临时目录。 + +## 组件职责 + +### 前端 WebIDE + +前端负责编辑体验和状态展示: + +- 文件树、当前文件查看或轻编辑。 +- Agent 输入。 +- agent job / preview build 状态。 +- 脱敏日志展示。 +- iframe 预览。 + +前端只连接 api-server / gateway: + +```text +POST agent_turn +GET job events +GET job logs +GET project / snapshot +GET preview build +iframe previewUrl +``` + +前端不得: + +- 直接连接 Remote Container。 +- 直接连接 Remote Agent Server 私有执行端口。 +- 持有 container token、SSH key、Docker API 凭据或 job lease token。 +- 暴露用户可交互 shell。 +- 自行拼接 preview URL。 + +### api-server 控制面 + +api-server 是所有业务状态和权限的入口: + +- 鉴权和 project owner 校验。 +- 创建 `agent_turn` / `preview_build` runtime job。 +- 读取 project、snapshot、job、logs、preview build。 +- 为 Remote Agent Server 发放短期任务能力。 +- 接收 Agent 产出的 patch、summary、日志摘要和结果状态。 +- 执行 patch 校验。 +- 保存新 snapshot。 +- 创建 preview build。 +- 提供取消、刷新恢复和 SSE / 日志分页。 + +api-server 不做: + +- 不启动用户 Container。 +- 不运行 Agent。 +- 不执行 shell。 +- 不把平台 `.env`、SpacetimeDB token、OSS 写权限、LLM provider key 下发给 workspace。 + +### SpacetimeDB 事实源 + +长期事实源包括: + +```text +web_project +web_project_snapshot +web_project_runtime_job +web_project_runtime_job_log +web_project_preview_build +``` + +事实源规则: + +- `web_project_snapshot` 是工程文件的版本化事实源。 +- `web_project_runtime_job` 描述执行生命周期,不替代 snapshot。 +- `web_project_runtime_job_log` 保存可恢复日志,不依赖前端内存。 +- `web_project_preview_build` 保存 preview artifact、token 和 preview URL。 +- Container 内 `/workspace` 只是一份可销毁执行副本。 + +### Remote Agent Server + +Remote Agent Server 是专门服务器上的执行控制器,建议由常驻 worker 主动拉任务: + +```text +claim job +renew lease +append log +run container +collect patch +complete / fail / cancel +``` + +它不直接写 SpacetimeDB 表,不绕过 api-server / spacetime-client facade。 + +推荐不要让 api-server 通过 SSH、Docker API 或远程 shell 主动控制专门服务器;Remote Agent Server 应主动 claim job,减少入站控制面。 + +### Remote Container + +P3 MVP 的同容器形态: + +```text +Remote Container + /workspace + /agent + /run/secrets/job_token +``` + +最低运行限制: + +- 非 root。 +- 无 Docker socket。 +- 无宿主源码挂载。 +- 无平台 `.env`。 +- 无 SpacetimeDB token。 +- 无长期 LLM provider key。 +- 仅挂载当前 job / project 工作区。 +- CPU、内存、磁盘、进程数、打开文件数、任务时长和日志大小受限。 +- 网络最小化。 + +后续隔离形态: + +```text +Agent Container + -> ToolBridge sidecar + -> Workspace Container +``` + +拆分后,Agent 的模型调用、规划和工具请求与 Workspace 的不可信代码执行隔离;但 snapshot / patch / job 状态机不变。 + +## Agent 与工作区关系 + +Agent 是用户开发助手,workspace 是不可信代码执行区。即使每个用户都有独立 Agent 实例和独立 workspace,也不应让 workspace 直接获得 Agent 的任务权限。 + +风险来自: + +- 用户代码伪造构建、测试、日志或 patch 结果。 +- 用户代码读取短期 job token 后伪造回调或滥用 LLM 配额。 +- 用户代码污染 Agent 工具链,让 Agent 基于假现场继续生成。 +- 用户代码把后门写回 snapshot。 + +P3 MVP 接受同容器的工程速度收益,但必须保留以下防线: + +- job token 最小权限、短期有效。 +- Agent 和 shell 输出都要审计和脱敏。 +- 最终文件变更只通过 patch 保存。 +- api-server patch validator 是唯一落库门。 +- 后续可拆容器,不改控制面契约。 + +## Shell 工具与过滤接口 + +用户不使用 shell。shell 只作为 Agent 的内部工具。 + +Agent 不能直接调用底层 `exec` / `spawn`。所有命令必须经过: + +```text +Agent + -> ShellTool.run(command) + -> ShellPolicy.evaluate(command, context) + -> CommandExecutor.exec(command) + -> OutputFilter.redact(output) + -> AuditLogger.record(attempt/result) +``` + +### ShellPolicy 接口 + +建议抽象: + +```text +ShellCommandContext + jobId + projectId + snapshotId + workingDirectory + agentTurnId + userId + +ShellPolicyDecision + allow + deny + require_approval + +ShellPolicy.evaluate(command, context) -> decision +``` + +P3 MVP 可实现: + +```text +AllowAllShellPolicy +``` + +但必须记录 `policyVersion=allow-all-v0`,并保留后续规则升级入口。 + +### 后续规则方向 + +ShellPolicy 后续可以加入: + +- 命令 allowlist / denylist。 +- 禁止读取 `/run/secrets`。 +- 禁止访问 `.env`、`.npmrc`、`.ssh`、`.git`。 +- 禁止后台进程。 +- 限制工作目录必须在 `/workspace`。 +- 限制执行时长、输出大小和并发数。 +- 命令风险分级。 +- 高风险命令要求人工批准。 +- 与 Container 网络策略联动。 + +### 日志与脱敏 + +内部审计日志保存完整结构化事件,但必须脱敏敏感值。用户可见日志只展示必要摘要。 + +每次命令至少记录: + +- `jobId` +- `projectId` +- `snapshotId` +- `command` +- `workingDirectory` +- `decision` +- `policyVersion` +- `startedAt` +- `finishedAt` +- `exitCode` +- 输出摘要 + +## LLM Broker + +Agent Container 不持有长期 provider key。模型调用统一走平台 `LLM Broker`: + +```text +Agent Container + -> short-lived job token + -> LLM Broker + -> OpenAI / Claude / other providers +``` + +LLM Broker 负责: + +- 校验 job token。 +- 校验 worker lease。 +- 校验 project / owner / job kind。 +- 模型路由。 +- 配额、速率限制和成本归因。 +- 审计与脱敏。 +- provider key 管理。 + +如果短期内必须让 Container 直连 provider,也只能注入单 job 短期 token,且必须可撤销、可审计;不得注入长期平台级 key。 + +## 前端与 Container 连接模型 + +前端不直接连接 Container。所有交互走平台控制面: + +```text +前端 + -> api-server HTTP: 创建 agent_turn + -> api-server SSE: 订阅 job events + -> api-server HTTP: 读取持久日志 + -> api-server HTTP: 读取 project / snapshot / build + -> Preview Gateway: iframe 预览 +``` + +不提供用户 shell WebSocket。若未来确实需要 shell UI,必须重新补安全模型和人工权限策略,不能复用 Agent 内部 shell 工具直接暴露。 + +## Patch 与 Snapshot 流 + +Agent 修改文件的正式流: + +```text +hydrate snapshot 到 workspace + -> Agent 修改文件 + -> collect diff + -> 转成 WebProjectPatch + -> api-server validate_web_project_patch + -> 保存新 snapshot + -> 创建 preview_build job +``` + +禁止: + +- Container 直接写 SpacetimeDB。 +- Container 直接修改 `web_project_snapshot`。 +- 将 workspace 路径写入业务数据。 +- 将临时 workspace 作为回滚来源。 +- failed / cancelled / expired / stale 覆盖上一版 active preview。 + +## Preview 与 iframe sandbox + +Preview 仍是独立面: + +```text +preview build job + -> runner / sandbox build + -> immutable artifact + -> Preview Gateway + -> iframe sandbox +``` + +iframe sandbox 是浏览器侧限制;Preview Gateway 是后端独立 origin / 独立 path 服务。二者共同保证 AI 生成代码不进入主站 JS 上下文,也拿不到主站 cookie / localStorage。 + +当前默认: + +```text +iframe sandbox="allow-scripts" +CSP connect-src 'none' +``` + +## 与 Game SDK 方向的关系 + +若 `/editor/agent` 后续收敛到“AI 快速生成小游戏”,应在现有架构上增加 Game SDK Runtime Profile,而不是绕过沙箱: + +```text +AI 生成 JS / TS + Canvas 2D + Web Audio + 平台 Game SDK + -> export createGame(ctx) + -> parent 提供 GameContext + -> ctx.assets / ctx.audio / ctx.events / ctx.finishCandidate / ctx.storage / ctx.random + -> 后端正式裁决 +``` + +生成代码不得直接 fetch、读 cookie、连外网、开摄像头或调用主站 API。Game SDK 约束属于 P3 之后的模板 / runtime profile 收敛,不是 P3 MVP 的阻塞项。 + +## 迁移策略 + +P3 同容器 MVP 到隔离形态的迁移目标: + +```text +同容器 Agent + workspace + -> AgentRuntime + ToolBridge 接口稳定 + -> ToolBridge 从本地函数换成 sidecar RPC + -> WorkspaceRuntime 从本地目录换成独立 Workspace Container + -> Agent Container 不再直接接触不可信 shell 环境 +``` + +低迁移成本依赖: + +- 不让 Agent 代码直接依赖宿主绝对路径。 +- 不把 shell executor 散落到业务代码里。 +- 不让 Container 直接落库。 +- 不让前端直连 Container。 +- 不把 workspace 当事实源。 +- 不把 provider key 放进 workspace。 + +## 验收要点 + +架构验收至少覆盖: + +- 前端只连 api-server / gateway。 +- `agent_turn` job 可恢复。 +- Remote Agent Server 主动 claim job。 +- Agent shell 全部经过 ShellPolicy。 +- ShellPolicy allow-all 也能产生命令审计记录。 +- LLM 调用不需要 Container 长期 provider key。 +- Container 产出 patch 后由 api-server 校验保存 snapshot。 +- preview build 独立触发,失败不覆盖上一版 preview。 +- 刷新后 project、snapshot、agent job、logs 和 active preview 均可恢复。 diff --git a/docs/technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md b/docs/technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md index 582ae6e1..c0b34b5c 100644 --- a/docs/technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md +++ b/docs/technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md @@ -340,15 +340,23 @@ draft snapshot Phase 2 的可执行计划见 [../planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md](../planning/【开发计划】EditorAgentP2持久任务与SandboxRuntime计划-2026-06-16.md)。执行顺序以“runtime job / worker / lease 先行,SandboxRuntime 抽象随后接入”为准;Docker 工作区、Shell 和真实 coding agent 都应挂在该任务系统之后。 -### Phase 3:受控依赖安装 +### Phase 3:远程 Agent Container MVP + +在 P2 的 runtime job、worker / lease、日志恢复和 `SandboxRuntime` 抽象稳定后,先加速接入真实 Agent 与远程 Container。第一版采用 `Agent + workspace` 同 Remote Container 的 MVP:前端仍只连 api-server,api-server 创建 `agent_turn` job,Remote Agent Server 主动 claim job,Container 内运行真实 Agent、工作区和 Agent 私有 shell,最终收集 diff 转 `WebProjectPatch`,再由 api-server 校验并保存 snapshot。 + +Phase 3 不向用户开放 shell;Agent 可使用完整 shell,但必须经过 `ShellPolicy` / `CommandGuard` 接口。MVP policy 可以先是 allow-all,但所有命令都要审计、脱敏和归入 job 日志。Container 不持有长期 LLM provider key,真实模型调用通过 `LLM Broker`;Container 也不能直接写 SpacetimeDB 或把 `/workspace` 提升为事实源。 + +真实 Agent、用户工作区、Agent 私有 shell、ShellPolicy / CommandGuard、ToolBridge、LLM Broker 和后续容器拆分的长期架构见 [【技术方案】EditorAgent远程Agent与工作区沙箱架构-2026-06-17.md](./【技术方案】EditorAgent远程Agent与工作区沙箱架构-2026-06-17.md)。P3 可执行计划见 [../planning/【开发计划】EditorAgentP3远程AgentContainerMVP计划-2026-06-17.md](../planning/【开发计划】EditorAgentP3远程AgentContainerMVP计划-2026-06-17.md)。后续将同容器 MVP 替换为 `Agent Container + Workspace Container + ToolBridge sidecar` 时,只替换执行面适配器,不推翻 snapshot / patch / preview 控制面。 + +### Phase 4:受控依赖安装 支持白名单依赖、lockfile 固化、依赖层缓存、安装失败可读错误、依赖封禁和审计。 -### Phase 4:实时体验增强 +### Phase 5:实时体验增强 在安全评审后再做长驻 dev server、HMR、WebSocket 代理、端口租约和空闲回收。 -### Phase 5:作品化发布 +### Phase 6:作品化发布 将通过安全门禁的 Web project 接入平台作品类型。发布必须重新构建 immutable artifact,不直接提升临时预览 artifact。 diff --git a/docs/technical/【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md b/docs/technical/【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md index f2d9b2fe..fe494ca2 100644 --- a/docs/technical/【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md +++ b/docs/technical/【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md @@ -205,28 +205,38 @@ MVP 不支持: ```bash cargo test -p api-server web_project --manifest-path server-rs/Cargo.toml cargo test -p spacetime-module web_project --manifest-path server-rs/Cargo.toml +cargo test -p spacetime-client web_project --manifest-path server-rs/Cargo.toml +cargo test -p web-project-runner --manifest-path server-rs/Cargo.toml ``` 前端: ```bash -npm run test -- src/services/sseStream.test.ts -npm run test -- src/components/editor/agent +npm run test -- src/services/web-project src/components/editor/agent --reporter verbose npm run typecheck npm run check:encoding git diff --check ``` +P2 schema 与 smoke 前置检查: + +```bash +npm run spacetime:generate +npm run check:spacetime-schema +npm run check:editor-agent-p2-smoke +``` + 浏览器 smoke: ```text 打开 /editor/agent -创建模板项目 -提交一次 AI patch -等待静态构建成功 +提交“做一个蓝色计数按钮页面” +等待 runtime job succeeded 确认 iframe 展示新预览 点击预览中的计数按钮,文案从 `已点击 0 次` 变为 `已点击 1 次` -提交一次故意破坏构建的 patch -确认错误出现且上一版预览仍保留 -刷新页面确认项目、日志和 active preview 可恢复 +提交“破坏构建” +确认 failed job 不覆盖上一版 preview +连续提交两次并确认旧 job 不覆盖新 preview +取消一个未完成 job +刷新页面确认 project、active snapshot、active preview、job 状态和日志恢复 ``` diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index cc437316..5dfc4efe 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -429,6 +429,46 @@ npm run check:server-rs-ddd - Rust 结构体:`DatabaseMigrationOperator` - 源码:`server-rs/crates/spacetime-module/src/migration.rs` +### `web_project` + +- Rust 结构体:`WebProject` +- 源码:`server-rs/crates/spacetime-module/src/web_project.rs` +- 说明:`/editor/agent` Web 工程项目真相表,保存 owner、标题、固定模板 key、active snapshot 和 active preview build;snapshot 仍是工程文件事实源,artifact / runner 临时目录不得反向成为业务真相。 +- 索引:`by_web_project_owner_user_id` 用于当前用户工程读取。 + +### `web_project_snapshot` + +- Rust 结构体:`WebProjectSnapshotRow` +- 源码:`server-rs/crates/spacetime-module/src/web_project.rs` +- 说明:Web 工程版本化 snapshot 表,保存固定模板工程文件 JSON、父 snapshot、创建来源和补丁摘要。P2 之后 sandbox workspace 只能从 snapshot hydrate,sandbox diff 必须转为 `WebProjectPatch` 并经 api-server 校验后保存为新 snapshot。 +- 索引:`by_web_project_snapshot_project_id`、`by_web_project_snapshot_owner_user_id`。 + +### `web_project_preview_build` + +- Rust 结构体:`WebProjectPreviewBuildRow` +- 源码:`server-rs/crates/spacetime-module/src/web_project.rs` +- 说明:P1 preview build 记录表,保存构建状态、短日志 JSON、artifact、preview token 和 preview URL。P2-04 起创建 preview build 的 HTTP 入口只负责创建该记录并入队 `web_project_runtime_job(kind=preview_build)`,不再在请求内占用进程 slot 或启动 runner;该表继续作为 preview build 对外契约和 active preview 指针来源,`active_preview_build_id` 只应在后续 worker 成功写回 artifact / preview token 后推进。 +- 索引:`by_web_project_preview_build_project_id`、`by_web_project_preview_build_snapshot_id`、`by_web_project_preview_build_owner_user_id`。 + +### `web_project_runtime_job` + +- Rust 结构体:`WebProjectRuntimeJobRow` +- 源码:`server-rs/crates/spacetime-module/src/web_project.rs` +- 说明:Editor Agent P2 Web Project 专用 runtime job 表,首期承接 `preview_build` 后台任务生命周期,保存 `queued / running / succeeded / failed / cancelled / expired / stale` 状态、attempt、worker lease、取消请求、stale 原因、artifact / preview build 关联和错误摘要。该表只描述执行任务,不替代 `web_project_snapshot` 作为工程事实源。 +- 状态机:P2-02 已在 SpacetimeDB procedure 中收口 `create / get / claim / renew / complete / fail / cancel / stale / expire / append log`;所有入口必须由 Web Project service identity 调用,worker 写回必须匹配 `worker_id + lease_token`,成功完成前必须校验 `snapshot_id == web_project.active_snapshot_id`,否则只能降级为 `stale`。 +- Facade/API:P2-03 起 `api-server` 只能通过 `spacetime-client` facade 操作 runtime job;HTTP 只暴露创建、读取、取消、未终态列表和日志分页,不直接下发 worker `lease_token`。新增只读 procedure `list_open_web_project_runtime_jobs_and_return` 用于刷新恢复时读取当前工程未终态任务。P2-04 起 preview build 创建响应在保留 `build` 的同时可返回 `runtimeJob`,用于前端恢复链路识别已入队任务;没有 worker 时任务保持 queued,不由 api-server 假装构建成功。 +- Worker:P2-05 起 `api-server` 支持 `GENARRATIVE_PROCESS_ROLE=web-project-runtime-worker` 独立运行 Web Project runtime worker,`all` 角色也会并行启动该 worker。worker 配置项为 `GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_ID`、`GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_CONCURRENCY`、`GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_POLL_INTERVAL_MS`、`GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_LEASE_SECONDS` 和 `GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_DRY_RUN_RESULT`。P2-06 起绑定 `preview_build_id` 的 `preview_build` job 会进入 `TempDirBuildRuntime`,hydrate snapshot 后调用受控 `web-project-runner` 子进程;无 preview build 绑定的内部 job 仍可按 dry-run 配置完成或失败。P2-07 起 runner 成功路径统一调用 `complete_web_project_preview_build_runtime_job_and_return` 组合 procedure,在同一 SpacetimeDB transaction 中写 runtime job、preview build 和 `active_preview_build_id`;写入 succeeded 前必须重新校验 `job.snapshot_id == web_project.active_snapshot_id`,旧 snapshot job 即使 runner 成功也只能把 runtime job 与 preview build 写成 `stale`,不生成 preview URL、不覆盖 active preview。 +- 取消 / 恢复 / smoke:P2-08 起取消请求优先于 worker 成功或失败结果,queued cancel 由 HTTP 同步收敛 linked preview build,running cancel 由 worker heartbeat 或最终续租处收敛;当前 `TempDirBuildRuntime` 不承诺强杀子进程。P2-09 起前端刷新和 SSE 断线恢复会先分页补拉 runtime job 持久日志,再恢复 build 状态和订阅。P2-10 起本地端到端 smoke 前置检查使用 `npm run check:editor-agent-p2-smoke` 读取 `.app/dev-stack.json` 并确认 dev 栈 ready;真实浏览器仍需手工确认 happy path、失败保留预览、连续提交 stale guard、取消和刷新恢复。 +- 索引:`by_web_project_runtime_job_project_id`、`by_web_project_runtime_job_snapshot_id`、`by_web_project_runtime_job_owner_user_id`、`by_web_project_runtime_job_status`。 + +### `web_project_runtime_job_log` + +- Rust 结构体:`WebProjectRuntimeJobLogRow` +- 源码:`server-rs/crates/spacetime-module/src/web_project.rs` +- 说明:Editor Agent P2 runtime job 持久日志表,按 `job_id + sequence` 支持日志分页、SSE 断线补拉和刷新恢复;日志必须脱敏,不能包含平台 token、完整宿主路径或环境变量 dump。 +- Facade/API:P2-03 起日志分页通过 `spacetime-client` facade 调用 `list_web_project_runtime_job_logs_and_return`;HTTP `GET /api/runtime/web-project/runtime-jobs/{job_id}/logs?afterSequence=&limit=` 返回 `logs / nextAfterSequence / hasMore`。 +- 索引:`by_web_project_runtime_job_log_job_id`、`by_web_project_runtime_job_log_project_id`、`by_web_project_runtime_job_log_owner_user_id`。 + ### `web_project_service_identity` - Rust 结构体:`WebProjectServiceIdentity` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 39a54228..d1c09609 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -50,6 +50,15 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模 后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`;需要确认实例可接生产流量时检查 `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 +`/editor/agent` P2 端到端 smoke 不能从裸 `npm run dev:api-server` 开始。`dev:api-server` 只启动 Rust API 进程,会在启动前向已存在的 SpacetimeDB 请求 `/v1/identity` 并确认 Web Project service identity;它不会自动启动 standalone,也不会发布当前 `spacetime-module`。需要验证 Web Project runtime job、worker、preview gateway 和浏览器 iframe 时,优先运行: + +```bash +npm run dev -- --no-interactive --api-timeout-seconds 900 +npm run check:editor-agent-p2-smoke +``` + +完整 `npm run dev` 会为同一个 Rust api-server 进程注入 `GENARRATIVE_PROCESS_ROLE=all`,本地同时启动 HTTP、外部生成 worker 和 Web Project runtime worker,但仍不会启动 external generation controller;生产默认仍保持 `api` 或独立 worker 进程。若需要拆分终端,先运行 `npm run dev:spacetime` 等待模块发布完成,再运行 `npm run dev:api-server` 和 `npm run dev:web`;手动单独启动 `GENARRATIVE_PROCESS_ROLE=web-project-runtime-worker` 时必须复用本轮 dev 脚本创建的 `GENARRATIVE_SPACETIME_TOKEN`,否则订阅 / procedure 调用会 401。Windows native 首次冷编译 / 发布 `spacetime-module` 可能超过 5 分钟,应预留 10-15 分钟;5 分钟内仍停在 publish 阶段通常不代表 P2 业务代码失败。`.app/dev-stack.json` 里的实际 web / api-server / spacetime / preview gateway URL 是 smoke 和浏览器访问的准确信息。 + 开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。 本地 `npm run dev` 和 `npm run dev:api-server` 默认保留 inline 开发体验:未显式设置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,外部生成 handler 会同步复用 worker executor,完成后返回 `completed`,便于快速确认 provider、OSS 和 SpacetimeDB 写回链路。inline 不创建 `external_generation_job`,也不能验证 worker lease、队列等待展示或动态扩缩容。 diff --git a/package.json b/package.json index de61e0f3..9a99fec2 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs", "check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs", "check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs", + "check:editor-agent-p2-smoke": "node scripts/check-editor-agent-p2-smoke.mjs", "check:server-rs-ddd": "npm run check:spacetime-schema && npm run check:spacetime-runtime-access && node scripts/check-server-rs-ddd-boundaries.mjs", "lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0", "lint:guardrails": "npm run lint:eslint", diff --git a/packages/shared/src/contracts/webProject.ts b/packages/shared/src/contracts/webProject.ts index 7a8e5c4f..2bcadbff 100644 --- a/packages/shared/src/contracts/webProject.ts +++ b/packages/shared/src/contracts/webProject.ts @@ -10,6 +10,17 @@ export type WebProjectPreviewBuildStatus = | 'expired' | 'stale'; +export type WebProjectRuntimeJobKind = 'preview_build'; + +export type WebProjectRuntimeJobStatus = + | 'queued' + | 'running' + | 'succeeded' + | 'failed' + | 'cancelled' + | 'expired' + | 'stale'; + export interface WebProject { projectId: string; ownerUserId: string; @@ -79,6 +90,38 @@ export interface WebProjectPreviewBuild { updatedAt: string; } +export interface WebProjectRuntimeJob { + jobId: string; + projectId: string; + snapshotId: string; + ownerUserId: string; + jobKind: WebProjectRuntimeJobKind; + status: WebProjectRuntimeJobStatus; + attempt: number; + workerId?: string | null; + leaseExpiresAt?: string | null; + cancelRequestedAt?: string | null; + staleReason?: string | null; + artifactId?: string | null; + previewBuildId?: string | null; + errorSummary?: string | null; + createdAt: string; + startedAt?: string | null; + finishedAt?: string | null; + updatedAt: string; +} + +export interface WebProjectRuntimeJobLog { + logId: string; + jobId: string; + projectId: string; + ownerUserId: string; + sequence: number; + level: string; + message: string; + createdAt: string; +} + export interface WebProjectPreviewBuildEvent { jobId: string; status: WebProjectPreviewBuildStatus; @@ -107,4 +150,25 @@ export interface WebProjectSnapshotResponse { export interface WebProjectPreviewBuildResponse { build: WebProjectPreviewBuild; + runtimeJob?: WebProjectRuntimeJob | null; +} + +export interface WebProjectRuntimeJobCreateRequest { + snapshotId?: string | null; + jobKind: WebProjectRuntimeJobKind; + previewBuildId?: string | null; +} + +export interface WebProjectRuntimeJobResponse { + job: WebProjectRuntimeJob; +} + +export interface WebProjectRuntimeJobListResponse { + jobs: WebProjectRuntimeJob[]; +} + +export interface WebProjectRuntimeJobLogListResponse { + logs: WebProjectRuntimeJobLog[]; + nextAfterSequence?: number | null; + hasMore: boolean; } diff --git a/scripts/check-editor-agent-p2-smoke.mjs b/scripts/check-editor-agent-p2-smoke.mjs new file mode 100644 index 00000000..b6ab755c --- /dev/null +++ b/scripts/check-editor-agent-p2-smoke.mjs @@ -0,0 +1,245 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const repoRoot = process.cwd(); +const defaultStatePath = join(repoRoot, '.app', 'dev-stack.json'); +const args = process.argv.slice(2); +const failures = []; +const warnings = []; + +const options = parseArgs(args); + +if (options.help) { + printUsage(); + process.exit(0); +} + +if (options.checklistOnly) { + printManualChecklist(); + process.exit(0); +} + +const state = readDevStackState(options.statePath); +if (state) { + await checkDevStack(state); +} + +if (warnings.length > 0) { + console.warn('\n[editor-agent-p2-smoke] 提醒:'); + for (const warning of warnings) { + console.warn(`- ${warning}`); + } +} + +if (failures.length > 0) { + console.error('\n[editor-agent-p2-smoke] 未通过:'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + console.error( + '\n先用 `npm run dev -- --no-interactive --api-timeout-seconds 900` 启动完整栈,等待 SpacetimeDB publish、api-server /healthz 和 P2 runtime worker 就绪后再重跑。', + ); + process.exit(1); +} + +console.log('\n[editor-agent-p2-smoke] dev 栈就绪检查通过。'); +printManualChecklist(state); + +function parseArgs(rawArgs) { + const parsed = { + checklistOnly: false, + help: false, + statePath: defaultStatePath, + }; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + switch (arg) { + case '--checklist-only': + parsed.checklistOnly = true; + break; + case '--state-path': { + const value = rawArgs[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error('--state-path 缺少路径参数'); + } + parsed.statePath = resolve(repoRoot, value); + index += 1; + break; + } + case '-h': + case '--help': + parsed.help = true; + break; + default: + throw new Error(`未知参数:${arg}`); + } + } + + return parsed; +} + +function printUsage() { + console.log(`用法: + npm run check:editor-agent-p2-smoke + npm run check:editor-agent-p2-smoke -- --checklist-only + +说明: + 默认读取 .app/dev-stack.json,检查 SpacetimeDB / api-server / web / preview gateway 已可访问。 + 完整 npm run dev 会为 api-server 注入 GENARRATIVE_PROCESS_ROLE=all,以便同进程消费 P2 Web Project runtime job。 + --checklist-only 只打印真实浏览器 smoke 步骤,不要求本地 dev 栈正在运行。 +`); +} + +function readDevStackState(statePath) { + if (!existsSync(statePath)) { + failures.push(`缺少 dev 栈状态文件:${statePath}`); + return null; + } + + try { + const state = JSON.parse(readFileSync(statePath, 'utf8')); + if (state.repoRoot && resolve(state.repoRoot) !== resolve(repoRoot)) { + failures.push( + `.app/dev-stack.json 属于其它工作区:${state.repoRoot},当前为 ${repoRoot}`, + ); + } + return state; + } catch (error) { + failures.push(`读取 dev 栈状态失败:${error.message}`); + return null; + } +} + +async function checkDevStack(state) { + if (state.command !== 'all') { + warnings.push( + `当前 dev 命令为 ${state.command ?? 'unknown'};P2 浏览器 smoke 建议使用完整 npm run dev,或确保拆分终端已启动 web-project-runtime-worker 并携带同一 SpacetimeDB token。`, + ); + } + + const spacetimeUrl = getServiceUrl(state, 'spacetime'); + const apiUrl = getServiceUrl(state, 'api-server'); + const webUrl = getServiceUrl(state, 'web'); + const previewUrl = getServiceUrl(state, 'web-project-preview'); + + await Promise.all([ + spacetimeUrl + ? expectHttp({ + label: 'SpacetimeDB /v1/ping', + url: joinUrl(spacetimeUrl, '/v1/ping'), + accept: (response) => response.ok, + }) + : Promise.resolve(), + apiUrl + ? expectJson({ + label: 'api-server /healthz', + url: joinUrl(apiUrl, '/healthz'), + validate: (json) => + json && json.ok === true && json.service === 'genarrative-api-server', + }) + : Promise.resolve(), + webUrl + ? expectHttp({ + label: 'web /editor/agent', + url: joinUrl(webUrl, '/editor/agent'), + accept: (response) => response.status >= 200 && response.status < 500, + }) + : Promise.resolve(), + previewUrl + ? expectHttp({ + label: 'preview gateway invalid token guard', + url: joinUrl(previewUrl, '/p/not-a-valid-preview-token/'), + accept: (response) => response.status === 410, + }) + : Promise.resolve(), + ]); +} + +function getServiceUrl(state, serviceName) { + const service = state.services?.[serviceName]; + const url = String(service?.url ?? '').trim(); + if (!url) { + failures.push(`dev 栈状态缺少 ${serviceName}.url`); + return ''; + } + + const status = String(service?.status ?? '').trim(); + const expectedStatus = serviceName === 'spacetime' ? ['running', 'reused'] : ['running']; + if (!expectedStatus.includes(status)) { + warnings.push(`${serviceName} 状态为 ${status || 'unknown'},将继续以 HTTP 探测为准。`); + } + return url; +} + +async function expectJson({ label, url, validate }) { + const response = await fetchForSmoke(label, url); + if (!response) { + return; + } + if (!response.ok) { + failures.push(`${label} 返回 HTTP ${response.status}:${url}`); + return; + } + + try { + const json = await response.json(); + if (!validate(json)) { + failures.push(`${label} 响应 JSON 不符合预期:${JSON.stringify(json)}`); + } else { + console.log(`[editor-agent-p2-smoke] OK ${label}: ${url}`); + } + } catch (error) { + failures.push(`${label} 响应不是合法 JSON:${error.message}`); + } +} + +async function expectHttp({ label, url, accept }) { + const response = await fetchForSmoke(label, url); + if (!response) { + return; + } + if (!accept(response)) { + failures.push(`${label} 返回 HTTP ${response.status}:${url}`); + return; + } + console.log(`[editor-agent-p2-smoke] OK ${label}: ${url}`); +} + +async function fetchForSmoke(label, url) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + try { + return await fetch(url, { + redirect: 'manual', + signal: controller.signal, + }); + } catch (error) { + failures.push(`${label} 无法访问:${url} (${error.message})`); + return null; + } finally { + clearTimeout(timer); + } +} + +function joinUrl(baseUrl, path) { + return new URL(path, `${baseUrl.replace(/\/+$/u, '')}/`).href; +} + +function printManualChecklist(state = null) { + const webUrl = String(state?.services?.web?.url ?? '').trim(); + const editorUrl = webUrl ? joinUrl(webUrl, '/editor/agent') : '/editor/agent'; + + console.log(` +[editor-agent-p2-smoke] 真实浏览器最小 smoke: +1. 打开 ${editorUrl} +2. 提交“做一个蓝色计数按钮页面”,等待 runtime job 进入 succeeded。 +3. 确认 iframe 显示蓝色计数按钮,并点击后从“已点击 0 次”变为“已点击 1 次”。 +4. 提交“破坏构建”,确认新 job failed 且上一版成功 preview URL 不变。 +5. 连续提交两次计数按钮改动,确认旧 snapshot job 不覆盖最新 active preview。 +6. 对一个 queued 或 running runtime job 点击取消,确认状态收敛为 cancelled。 +7. 刷新页面,确认 project、active snapshot、active preview、job 状态和日志从后端恢复。 + +提示:完整 npm run dev 会自动使用 GENARRATIVE_PROCESS_ROLE=all;拆分终端手动启动 worker 时必须复用本轮 dev 脚本创建的 GENARRATIVE_SPACETIME_TOKEN。 +`); +} diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 3baa3b7c..d34320a3 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -1475,6 +1475,7 @@ class DevRunner { baseEnv: buildLocalRustProcessEnv(this.baseEnv), options: this.options, state: this.state, + processRole: this.command === 'all' ? 'all' : '', }); const logFile = resolveApiServerLogFile(repoRoot, mergedEnv); @@ -2537,9 +2538,16 @@ function isSpacetimePublishPermissionError(error) { ); } -function buildApiServerProcessEnv({baseEnv, options, state}) { +function buildApiServerProcessEnv({baseEnv, options, state, processRole = ''}) { + const resolvedProcessRole = + String(baseEnv.GENARRATIVE_PROCESS_ROLE ?? '').trim() || + String(processRole ?? '').trim(); + return { ...baseEnv, + ...(resolvedProcessRole + ? {GENARRATIVE_PROCESS_ROLE: resolvedProcessRole} + : {}), // 本地 dev 允许密码入口直接创建账号,生产默认仍由 api-server 配置保持关闭。 GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED: 'true', GENARRATIVE_API_HOST: options.apiHost, diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index 79eb960e..24706b90 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -214,6 +214,41 @@ describe('dev scheduler api-server env', () => { ); }); + test('完整 dev 栈默认以 all 角色启动 api-server 和 P2 worker', () => { + const {options} = parseArgs([], {}); + const env = buildApiServerProcessEnv({ + baseEnv: {}, + options, + state: {spacetimeServer: 'http://127.0.0.1:3101'}, + processRole: 'all', + }); + + expect(env.GENARRATIVE_PROCESS_ROLE).toBe('all'); + }); + + test('dev 脚本保留显式 GENARRATIVE_PROCESS_ROLE', () => { + const {options} = parseArgs([], {}); + const env = buildApiServerProcessEnv({ + baseEnv: {GENARRATIVE_PROCESS_ROLE: 'web-project-runtime-worker'}, + options, + state: {spacetimeServer: 'http://127.0.0.1:3101'}, + processRole: 'all', + }); + + expect(env.GENARRATIVE_PROCESS_ROLE).toBe('web-project-runtime-worker'); + }); + + test('单独 dev:api-server 不默认注入进程角色', () => { + const {options} = parseArgs(['api-server'], {}); + const env = buildApiServerProcessEnv({ + baseEnv: {}, + options, + state: {spacetimeServer: 'http://127.0.0.1:3101'}, + }); + + expect(env.GENARRATIVE_PROCESS_ROLE).toBeUndefined(); + }); + test('dev 脚本保留显式 Web Project runner 二进制路径', () => { const {options} = parseArgs(['api-server'], {}); const env = buildApiServerProcessEnv({ diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 4359331c..8762c42b 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -28,6 +28,11 @@ pub struct AppConfig { pub external_generation_worker_concurrency: usize, pub external_generation_worker_poll_interval: Duration, pub external_generation_worker_lease: Duration, + pub web_project_runtime_worker_id: String, + pub web_project_runtime_worker_concurrency: usize, + pub web_project_runtime_worker_poll_interval: Duration, + pub web_project_runtime_worker_lease: Duration, + pub web_project_runtime_worker_dry_run_result: WebProjectRuntimeWorkerDryRunResult, pub external_generation_controller_min_workers: usize, pub external_generation_controller_max_workers: usize, pub external_generation_controller_target_jobs_per_worker: usize, @@ -196,6 +201,7 @@ pub enum ProcessRole { Api, ExternalGenerationWorker, ExternalGenerationController, + WebProjectRuntimeWorker, All, } @@ -205,6 +211,12 @@ pub enum ExternalGenerationMode { Queue, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WebProjectRuntimeWorkerDryRunResult { + Fail, + Succeed, +} + impl ExternalGenerationMode { pub fn as_str(self) -> &'static str { match self { @@ -218,12 +230,22 @@ impl ExternalGenerationMode { } } +impl WebProjectRuntimeWorkerDryRunResult { + pub fn as_str(self) -> &'static str { + match self { + Self::Fail => "fail", + Self::Succeed => "success", + } + } +} + impl ProcessRole { pub fn as_str(self) -> &'static str { match self { Self::Api => "api", Self::ExternalGenerationWorker => "external-generation-worker", Self::ExternalGenerationController => "external-generation-controller", + Self::WebProjectRuntimeWorker => "web-project-runtime-worker", Self::All => "all", } } @@ -239,6 +261,10 @@ impl ProcessRole { pub fn runs_external_generation_controller(self) -> bool { matches!(self, Self::ExternalGenerationController) } + + pub fn runs_web_project_runtime_worker(self) -> bool { + matches!(self, Self::WebProjectRuntimeWorker | Self::All) + } } impl Default for AppConfig { @@ -254,6 +280,11 @@ impl Default for AppConfig { external_generation_worker_concurrency: 2, external_generation_worker_poll_interval: Duration::from_millis(2_000), external_generation_worker_lease: Duration::from_secs(3_600), + web_project_runtime_worker_id: default_worker_id("web-project-runtime"), + web_project_runtime_worker_concurrency: 2, + web_project_runtime_worker_poll_interval: Duration::from_millis(2_000), + web_project_runtime_worker_lease: Duration::from_secs(600), + web_project_runtime_worker_dry_run_result: WebProjectRuntimeWorkerDryRunResult::Fail, external_generation_controller_min_workers: 1, external_generation_controller_max_workers: 8, external_generation_controller_target_jobs_per_worker: 2, @@ -495,6 +526,32 @@ impl AppConfig { ]) { config.external_generation_worker_lease = Duration::from_secs(lease_seconds.max(1)); } + if let Some(worker_id) = + read_first_non_empty_env(&["GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_ID"]) + { + config.web_project_runtime_worker_id = worker_id; + } + if let Some(concurrency) = + read_first_usize_env(&["GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_CONCURRENCY"]) + { + config.web_project_runtime_worker_concurrency = concurrency.max(1); + } + if let Some(poll_interval_ms) = read_first_positive_u64_env(&[ + "GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_POLL_INTERVAL_MS", + ]) { + config.web_project_runtime_worker_poll_interval = + Duration::from_millis(poll_interval_ms); + } + if let Some(lease_seconds) = read_first_duration_seconds_env(&[ + "GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_LEASE_SECONDS", + ]) { + config.web_project_runtime_worker_lease = Duration::from_secs(lease_seconds.max(1)); + } + if let Some(dry_run_result) = read_first_web_project_runtime_worker_dry_run_result_env(&[ + "GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_DRY_RUN_RESULT", + ]) { + config.web_project_runtime_worker_dry_run_result = dry_run_result; + } if let Some(min_workers) = read_first_usize_env(&["GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MIN_WORKERS"]) { @@ -1270,6 +1327,16 @@ fn read_first_external_generation_mode_env(keys: &[&str]) -> Option Option { + keys.iter().find_map(|key| { + env::var(key) + .ok() + .and_then(|value| parse_web_project_runtime_worker_dry_run_result(value.as_str())) + }) +} + fn read_first_positive_u32_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) @@ -1324,6 +1391,13 @@ fn default_external_generation_worker_id() -> String { format!("{}-{}", host.trim(), std::process::id()) } +fn default_worker_id(prefix: &str) -> String { + let host = env::var("HOSTNAME") + .or_else(|_| env::var("COMPUTERNAME")) + .unwrap_or_else(|_| "local".to_string()); + format!("{}-{}-{}", prefix.trim(), host.trim(), std::process::id()) +} + fn parse_process_role(value: &str) -> Option { match trim_quoted_env_value(value).to_ascii_lowercase().as_str() { "api" => Some(ProcessRole::Api), @@ -1333,6 +1407,10 @@ fn parse_process_role(value: &str) -> Option { "external-generation-controller" | "external_generation_controller" | "controller" => { Some(ProcessRole::ExternalGenerationController) } + "web-project-runtime-worker" + | "web_project_runtime_worker" + | "web-project-worker" + | "web_project_worker" => Some(ProcessRole::WebProjectRuntimeWorker), "all" => Some(ProcessRole::All), _ => None, } @@ -1348,6 +1426,18 @@ fn parse_external_generation_mode(value: &str) -> Option } } +fn parse_web_project_runtime_worker_dry_run_result( + value: &str, +) -> Option { + match trim_quoted_env_value(value).to_ascii_lowercase().as_str() { + "fail" | "failed" | "failure" | "error" => Some(WebProjectRuntimeWorkerDryRunResult::Fail), + "success" | "succeed" | "succeeded" | "ok" => { + Some(WebProjectRuntimeWorkerDryRunResult::Succeed) + } + _ => None, + } +} + fn trim_quoted_env_value(raw: &str) -> &str { let raw = raw.trim(); raw.strip_prefix('"') @@ -1481,7 +1571,9 @@ fn parse_positive_u16(raw: &str) -> Option { mod tests { use super::{ AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, ExternalGenerationMode, - LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role, + LlmProvider, ProcessRole, WebProjectRuntimeWorkerDryRunResult, parse_bool, + parse_external_generation_mode, parse_process_role, + parse_web_project_runtime_worker_dry_run_result, }; use std::sync::{Mutex, OnceLock}; @@ -1546,21 +1638,41 @@ mod tests { parse_process_role("'external_generation_controller'"), Some(ProcessRole::ExternalGenerationController) ); + assert_eq!( + parse_process_role("\"web-project-runtime-worker\""), + Some(ProcessRole::WebProjectRuntimeWorker) + ); + assert_eq!( + parse_process_role("'web_project_runtime_worker'"), + Some(ProcessRole::WebProjectRuntimeWorker) + ); + assert_eq!( + parse_process_role("web-project-worker"), + Some(ProcessRole::WebProjectRuntimeWorker) + ); assert_eq!(parse_process_role("all"), Some(ProcessRole::All)); assert_eq!(parse_process_role("unknown"), None); assert!(ProcessRole::Api.runs_http()); assert!(!ProcessRole::Api.runs_external_generation_worker()); assert!(!ProcessRole::Api.runs_external_generation_controller()); + assert!(!ProcessRole::Api.runs_web_project_runtime_worker()); assert!(!ProcessRole::ExternalGenerationWorker.runs_http()); assert!(ProcessRole::ExternalGenerationWorker.runs_external_generation_worker()); assert!(!ProcessRole::ExternalGenerationWorker.runs_external_generation_controller()); + assert!(!ProcessRole::ExternalGenerationWorker.runs_web_project_runtime_worker()); assert!(!ProcessRole::ExternalGenerationController.runs_http()); assert!(!ProcessRole::ExternalGenerationController.runs_external_generation_worker()); assert!(ProcessRole::ExternalGenerationController.runs_external_generation_controller()); + assert!(!ProcessRole::ExternalGenerationController.runs_web_project_runtime_worker()); + assert!(!ProcessRole::WebProjectRuntimeWorker.runs_http()); + assert!(!ProcessRole::WebProjectRuntimeWorker.runs_external_generation_worker()); + assert!(!ProcessRole::WebProjectRuntimeWorker.runs_external_generation_controller()); + assert!(ProcessRole::WebProjectRuntimeWorker.runs_web_project_runtime_worker()); assert!(ProcessRole::All.runs_http()); assert!(ProcessRole::All.runs_external_generation_worker()); assert!(!ProcessRole::All.runs_external_generation_controller()); + assert!(ProcessRole::All.runs_web_project_runtime_worker()); } #[test] @@ -1587,6 +1699,23 @@ mod tests { assert!(!ExternalGenerationMode::Queue.is_inline()); } + #[test] + fn web_project_runtime_worker_dry_run_result_parses_aliases() { + assert_eq!( + parse_web_project_runtime_worker_dry_run_result("\"success\""), + Some(WebProjectRuntimeWorkerDryRunResult::Succeed) + ); + assert_eq!( + parse_web_project_runtime_worker_dry_run_result("'failed'"), + Some(WebProjectRuntimeWorkerDryRunResult::Fail) + ); + assert_eq!( + parse_web_project_runtime_worker_dry_run_result("unknown"), + None + ); + assert_eq!(WebProjectRuntimeWorkerDryRunResult::Fail.as_str(), "fail"); + } + #[test] fn from_env_reads_external_generation_mode() { let _guard = ENV_LOCK @@ -1752,6 +1881,11 @@ mod tests { std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS"); std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS"); std::env::remove_var("GENARRATIVE_WEB_PROJECT_PREVIEW_BUILD_MAX_CONCURRENT_TASKS"); + std::env::remove_var("GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_ID"); + std::env::remove_var("GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_CONCURRENCY"); + std::env::remove_var("GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_POLL_INTERVAL_MS"); + std::env::remove_var("GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_LEASE_SECONDS"); + std::env::remove_var("GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_DRY_RUN_RESULT"); std::env::remove_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR"); @@ -1774,6 +1908,20 @@ mod tests { "GENARRATIVE_WEB_PROJECT_PREVIEW_BUILD_MAX_CONCURRENT_TASKS", "3", ); + std::env::set_var( + "GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_ID", + "web-worker-test", + ); + std::env::set_var("GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_CONCURRENCY", "4"); + std::env::set_var( + "GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_POLL_INTERVAL_MS", + "1500", + ); + std::env::set_var("GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_LEASE_SECONDS", "90"); + std::env::set_var( + "GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_DRY_RUN_RESULT", + "success", + ); std::env::set_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS", "3000"); std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED", "false"); std::env::set_var( @@ -1802,6 +1950,20 @@ mod tests { assert_eq!(config.detail_max_concurrent_requests, Some(32)); assert_eq!(config.admin_max_concurrent_requests, Some(16)); assert_eq!(config.web_project_preview_build_max_concurrent_tasks, 3); + assert_eq!(config.web_project_runtime_worker_id, "web-worker-test"); + assert_eq!(config.web_project_runtime_worker_concurrency, 4); + assert_eq!( + config.web_project_runtime_worker_poll_interval, + std::time::Duration::from_millis(1_500) + ); + assert_eq!( + config.web_project_runtime_worker_lease, + std::time::Duration::from_secs(90) + ); + assert_eq!( + config.web_project_runtime_worker_dry_run_result, + WebProjectRuntimeWorkerDryRunResult::Succeed + ); assert_eq!( config.shutdown_outbox_flush_timeout, std::time::Duration::from_millis(3_000) @@ -1838,6 +2000,11 @@ mod tests { std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS"); std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS"); std::env::remove_var("GENARRATIVE_WEB_PROJECT_PREVIEW_BUILD_MAX_CONCURRENT_TASKS"); + std::env::remove_var("GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_ID"); + std::env::remove_var("GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_CONCURRENCY"); + std::env::remove_var("GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_POLL_INTERVAL_MS"); + std::env::remove_var("GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_LEASE_SECONDS"); + std::env::remove_var("GENARRATIVE_WEB_PROJECT_RUNTIME_WORKER_DRY_RUN_RESULT"); std::env::remove_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR"); diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index dced8274..48d833ce 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -97,6 +97,8 @@ mod wallet_refund_outbox; mod web_project; mod web_project_mock_agent; mod web_project_preview_gateway; +mod web_project_preview_runtime; +mod web_project_runtime_worker; mod wechat; mod wooden_fish; mod work_author; @@ -126,6 +128,7 @@ use crate::{ state::{AppState, AppStateInitError}, tracking_outbox::TrackingOutbox, wallet_refund_outbox::WalletRefundOutbox, + web_project_runtime_worker::run_web_project_runtime_worker, }; const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024; @@ -140,6 +143,20 @@ struct ShutdownContext { outbox_flush_timeout: Duration, } +enum HttpRoleWorkerState { + ExternalGeneration(AppState), + WebProjectRuntime(AppState), +} + +impl HttpRoleWorkerState { + async fn run(self) -> Result<(), io::Error> { + match self { + Self::ExternalGeneration(state) => run_external_generation_worker(state).await, + Self::WebProjectRuntime(state) => run_web_project_runtime_worker(state).await, + } + } +} + fn main() -> Result<(), io::Error> { // Windows 本地调试下 Axum 路由树和启动恢复链较重,显式放大启动线程栈,避免 debug 构建在进入监听前栈溢出。 let server_thread = thread::Builder::new() @@ -187,11 +204,7 @@ async fn run_worker_only(config: AppConfig) -> Result<(), io::Error> { let process_role = config.process_role; let state = restore_app_state_for_startup(config) .await - .map_err(|error| { - io::Error::other(format!( - "初始化 external generation worker 状态失败:{error}" - )) - })?; + .map_err(|error| io::Error::other(format!("初始化非 HTTP 进程角色状态失败:{error}")))?; spawn_app_state_background_workers(&state); info!( process_role = process_role.as_str(), @@ -201,6 +214,8 @@ async fn run_worker_only(config: AppConfig) -> Result<(), io::Error> { run_external_generation_worker(state).await } else if process_role.runs_external_generation_controller() { run_external_generation_worker_controller(state).await + } else if process_role.runs_web_project_runtime_worker() { + run_web_project_runtime_worker(state).await } else { Err(io::Error::other(format!( "不支持的非 HTTP 进程角色:{}", @@ -218,44 +233,48 @@ async fn run_http_role(config: AppConfig) -> Result<(), io::Error> { let outbox_flush_timeout = config.shutdown_outbox_flush_timeout; let listener = build_tcp_listener(bind_address, listen_backlog)?; - let (router, shutdown_context, worker_state) = match restore_app_state_for_startup(config).await - { - Ok(state) => { - spawn_app_state_background_workers(&state); - let preview_shutdown = shutdown_signal_for_preview(state.clone()); - spawn_web_project_preview_gateway(&state, preview_shutdown).await?; - let tracking_outbox = state.tracking_outbox(); - let wallet_refund_outbox = state.wallet_refund_outbox(); - let worker_state = process_role - .runs_external_generation_worker() - .then(|| state.clone()); - ( - build_router(state.clone()), + let (router, shutdown_context, worker_states) = + match restore_app_state_for_startup(config).await { + Ok(state) => { + spawn_app_state_background_workers(&state); + let preview_shutdown = shutdown_signal_for_preview(state.clone()); + spawn_web_project_preview_gateway(&state, preview_shutdown).await?; + let tracking_outbox = state.tracking_outbox(); + let wallet_refund_outbox = state.wallet_refund_outbox(); + let mut worker_states = Vec::new(); + if process_role.runs_external_generation_worker() { + worker_states.push(HttpRoleWorkerState::ExternalGeneration(state.clone())); + } + if process_role.runs_web_project_runtime_worker() { + worker_states.push(HttpRoleWorkerState::WebProjectRuntime(state.clone())); + } + ( + build_router(state.clone()), + ShutdownContext { + app_state: Some(state), + tracking_outbox, + wallet_refund_outbox, + outbox_flush_timeout, + }, + worker_states, + ) + } + Err(AppStateInitError::DependencyUnavailable(message)) => ( + build_spacetime_unavailable_router(message), ShutdownContext { - app_state: Some(state), - tracking_outbox, - wallet_refund_outbox, + app_state: None, + tracking_outbox: None, + wallet_refund_outbox: None, outbox_flush_timeout, }, - worker_state, - ) - } - Err(AppStateInitError::DependencyUnavailable(message)) => ( - build_spacetime_unavailable_router(message), - ShutdownContext { - app_state: None, - tracking_outbox: None, - wallet_refund_outbox: None, - outbox_flush_timeout, - }, - None, - ), - Err(error) => { - return Err(std::io::Error::other(format!( - "初始化应用状态失败:{error}" - ))); - } - }; + Vec::new(), + ), + Err(error) => { + return Err(std::io::Error::other(format!( + "初始化应用状态失败:{error}" + ))); + } + }; info!( %bind_address, @@ -268,13 +287,23 @@ async fn run_http_role(config: AppConfig) -> Result<(), io::Error> { let http_server = axum::serve(listener, router) .with_graceful_shutdown(shutdown_signal(shutdown_context.clone())); - let result = if let Some(worker_state) = worker_state { + let result = if worker_states.is_empty() { + http_server.await + } else { + let mut worker_tasks = tokio::task::JoinSet::new(); + for worker_state in worker_states { + worker_tasks.spawn(async move { worker_state.run().await }); + } tokio::select! { result = http_server => result, - result = run_external_generation_worker(worker_state) => result, + result = worker_tasks.join_next() => match result { + Some(Ok(result)) => result, + Some(Err(error)) => Err(io::Error::other(format!( + "api-server HTTP 角色后台 worker panic:{error}" + ))), + None => Ok(()), + }, } - } else { - http_server.await }; finalize_shutdown(shutdown_context).await; result diff --git a/server-rs/crates/api-server/src/modules/web_project.rs b/server-rs/crates/api-server/src/modules/web_project.rs index a272eb3d..2352f96c 100644 --- a/server-rs/crates/api-server/src/modules/web_project.rs +++ b/server-rs/crates/api-server/src/modules/web_project.rs @@ -7,8 +7,10 @@ use crate::{ auth::require_bearer_auth, state::AppState, web_project::{ - create_mock_agent_turn, create_web_project, create_web_project_preview_build, - get_web_project, get_web_project_preview_build, get_web_project_snapshot, + cancel_web_project_runtime_job, create_mock_agent_turn, create_web_project, + create_web_project_preview_build, create_web_project_runtime_job, get_web_project, + get_web_project_preview_build, get_web_project_runtime_job, get_web_project_snapshot, + list_open_web_project_runtime_jobs, list_web_project_runtime_job_logs, patch_web_project_files, stream_web_project_preview_build_events, }, }; @@ -57,6 +59,15 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/web-project/projects/{project_id}/runtime-jobs", + post(create_web_project_runtime_job) + .get(list_open_web_project_runtime_jobs) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/web-project/preview-builds/{job_id}", get(get_web_project_preview_build).route_layer(middleware::from_fn_with_state( @@ -64,6 +75,27 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/web-project/runtime-jobs/{job_id}", + get(get_web_project_runtime_job).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/web-project/runtime-jobs/{job_id}/cancel", + post(cancel_web_project_runtime_job).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/web-project/runtime-jobs/{job_id}/logs", + get(list_web_project_runtime_job_logs).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/web-project/preview-builds/{job_id}/events", get(stream_web_project_preview_build_events) diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index d8570743..8875f223 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -273,7 +273,9 @@ pub struct AppStateInner { creative_agent_sessions: Arc>>, profile_recharge_order_updates: broadcast::Sender, web_project_build_updates: broadcast::Sender, + #[allow(dead_code)] web_project_preview_build_limiter: Arc, + #[allow(dead_code)] web_project_preview_active_projects: Arc>>, #[cfg(test)] // 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。 @@ -287,12 +289,14 @@ struct CreativeAgentSessionRuntimeRecord { } #[derive(Debug)] +#[allow(dead_code)] pub enum WebProjectPreviewBuildSlotError { GlobalLimit, ProjectAlreadyRunning, } #[derive(Debug)] +#[allow(dead_code)] pub struct WebProjectPreviewBuildSlot { project_key: String, active_projects: Arc>>, @@ -955,6 +959,7 @@ impl AppState { self.web_project_build_updates.clone() } + #[allow(dead_code)] pub fn try_acquire_web_project_preview_build_slot( &self, project_id: &str, diff --git a/server-rs/crates/api-server/src/web_project.rs b/server-rs/crates/api-server/src/web_project.rs index 5050078a..38bf0b42 100644 --- a/server-rs/crates/api-server/src/web_project.rs +++ b/server-rs/crates/api-server/src/web_project.rs @@ -9,7 +9,7 @@ use std::{ use async_stream::stream; use axum::{ Json, - extract::{Extension, Path, State}, + extract::{Extension, Path, Query, State}, http::StatusCode, response::{ IntoResponse, Response, @@ -22,14 +22,21 @@ use shared_contracts::web_project::{ MockAgentTurnRequest, MockAgentTurnResponse, WebProjectFile, WebProjectPatch, WebProjectPatchOperation, WebProjectPreviewBuild, WebProjectPreviewBuildEvent, WebProjectPreviewBuildResponse, WebProjectPreviewBuildStatus, WebProjectResponse, - WebProjectSnapshot, WebProjectSnapshotResponse, + WebProjectRuntimeJob, WebProjectRuntimeJobCreateRequest, WebProjectRuntimeJobKind, + WebProjectRuntimeJobListResponse, WebProjectRuntimeJobLog, WebProjectRuntimeJobLogListResponse, + WebProjectRuntimeJobResponse, WebProjectRuntimeJobStatus, WebProjectSnapshot, + WebProjectSnapshotResponse, }; use shared_kernel::{build_prefixed_uuid_id, new_uuid_simple_string}; use spacetime_client::{ SpacetimeClientError, WebProjectCreateRecordInput, WebProjectFileRecord, WebProjectGetRecordInput, WebProjectPreviewBuildCreateRecordInput, WebProjectPreviewBuildGetRecordInput, WebProjectPreviewBuildRecord, WebProjectRecord, - WebProjectSnapshotGetRecordInput, WebProjectSnapshotRecord, WebProjectSnapshotSaveRecordInput, + WebProjectRuntimeJobCancelRecordInput, WebProjectRuntimeJobCreateRecordInput, + WebProjectRuntimeJobGetRecordInput, WebProjectRuntimeJobListLogsRecordInput, + WebProjectRuntimeJobListOpenRecordInput, WebProjectRuntimeJobLogRecord, + WebProjectRuntimeJobRecord, WebProjectSnapshotGetRecordInput, WebProjectSnapshotRecord, + WebProjectSnapshotSaveRecordInput, }; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, @@ -47,16 +54,22 @@ use crate::{ request_context::RequestContext, state::{AppState, WebProjectPreviewBuildSlot, WebProjectPreviewBuildSlotError}, web_project_mock_agent::build_mock_agent_patch, + web_project_preview_runtime, }; const WEB_PROJECT_ID_PREFIX: &str = "web-project-"; const WEB_PROJECT_SNAPSHOT_ID_PREFIX: &str = "web-snapshot-"; const WEB_PROJECT_BUILD_ID_PREFIX: &str = "web-build-"; +const WEB_PROJECT_RUNTIME_JOB_ID_PREFIX: &str = "web-runtime-job-"; const WEB_PROJECT_DEFAULT_TITLE: &str = "未命名 Web 工程"; const WEB_PROJECT_MAX_PATH_DEPTH: usize = 8; const WEB_PROJECT_MAX_FILE_COUNT: usize = 80; const WEB_PROJECT_MAX_FILE_BYTES: usize = 128 * 1024; const WEB_PROJECT_MAX_SNAPSHOT_BYTES: usize = 512 * 1024; +const WEB_PROJECT_RUNTIME_JOB_DEFAULT_LIST_LIMIT: u32 = 20; +const WEB_PROJECT_RUNTIME_JOB_DEFAULT_LOG_LIMIT: u32 = 100; +const WEB_PROJECT_RUNTIME_JOB_MAX_LOG_LIMIT: u32 = 100; +#[allow(dead_code)] const WEB_PROJECT_RUNNER_TIMEOUT: Duration = Duration::from_secs(300); #[derive(Debug, Deserialize)] @@ -79,6 +92,19 @@ pub struct WebProjectPreviewBuildCreateRequest { snapshot_id: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebProjectRuntimeJobListQuery { + limit: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebProjectRuntimeJobLogsQuery { + after_sequence: Option, + limit: Option, +} + pub async fn create_web_project( State(state): State, Extension(request_context): Extension, @@ -276,32 +302,53 @@ pub async fn create_web_project_preview_build( .active_snapshot_id } }; - let build_slot = state - .try_acquire_web_project_preview_build_slot(&project_id) - .map_err(|error| map_preview_build_slot_error(error, project_id.as_str()))?; + let now_micros = current_utc_micros(); let mutation = state .spacetime_client() .create_web_project_preview_build(WebProjectPreviewBuildCreateRecordInput { job_id: build_prefixed_uuid_id(WEB_PROJECT_BUILD_ID_PREFIX), - project_id, - snapshot_id, - owner_user_id, - now_micros: current_utc_micros(), + project_id: project_id.clone(), + snapshot_id: snapshot_id.clone(), + owner_user_id: owner_user_id.clone(), + now_micros, }) .await .map_err(map_web_project_client_error)?; let mut build = build_from_record(mutation.build); build.logs.push("构建任务已进入队列".to_string()); + let runtime_job = match state + .spacetime_client() + .create_web_project_runtime_job(WebProjectRuntimeJobCreateRecordInput { + job_id: build_prefixed_uuid_id(WEB_PROJECT_RUNTIME_JOB_ID_PREFIX), + project_id, + snapshot_id, + owner_user_id: owner_user_id.clone(), + job_kind: runtime_job_kind_to_record(WebProjectRuntimeJobKind::PreviewBuild), + preview_build_id: Some(build.job_id.clone()), + now_micros, + }) + .await + { + Ok(runtime_job) => runtime_job, + Err(error) => { + let failed = mark_preview_build_queue_failed( + &state, + &owner_user_id, + build, + format!("runtime job 入队失败:{error}"), + ) + .await; + publish_build_event(&state, &failed, failed.error_summary.as_deref()); + return Err(map_web_project_client_error(error)); + } + }; publish_build_event(&state, &build, Some("构建任务已进入队列")); - spawn_preview_build_task( - state.clone(), - authenticated.claims().user_id().to_string(), - build.clone(), - build_slot, - ); Ok(json_success_body( Some(&request_context), - WebProjectPreviewBuildResponse { build }, + WebProjectPreviewBuildResponse { + build, + runtime_job: Some(runtime_job_from_record(runtime_job)), + }, )) } @@ -323,6 +370,173 @@ pub async fn get_web_project_preview_build( Some(&request_context), WebProjectPreviewBuildResponse { build: build_from_record(mutation.build), + runtime_job: None, + }, + )) +} + +pub async fn create_web_project_runtime_job( + State(state): State, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + let snapshot_id = match payload.snapshot_id { + Some(snapshot_id) if !snapshot_id.trim().is_empty() => snapshot_id, + _ => { + state + .spacetime_client() + .get_web_project(WebProjectGetRecordInput { + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + }) + .await + .map_err(map_web_project_client_error)? + .active_snapshot_id + } + }; + let job = state + .spacetime_client() + .create_web_project_runtime_job(WebProjectRuntimeJobCreateRecordInput { + job_id: build_prefixed_uuid_id(WEB_PROJECT_RUNTIME_JOB_ID_PREFIX), + project_id, + snapshot_id, + owner_user_id, + job_kind: runtime_job_kind_to_record(payload.job_kind), + preview_build_id: payload.preview_build_id, + now_micros: current_utc_micros(), + }) + .await + .map_err(map_web_project_client_error)?; + + Ok(json_success_body( + Some(&request_context), + WebProjectRuntimeJobResponse { + job: runtime_job_from_record(job), + }, + )) +} + +pub async fn get_web_project_runtime_job( + State(state): State, + Path(job_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let job = state + .spacetime_client() + .get_web_project_runtime_job(WebProjectRuntimeJobGetRecordInput { + job_id, + owner_user_id: authenticated.claims().user_id().to_string(), + }) + .await + .map_err(map_web_project_client_error)?; + + Ok(json_success_body( + Some(&request_context), + WebProjectRuntimeJobResponse { + job: runtime_job_from_record(job), + }, + )) +} + +pub async fn list_open_web_project_runtime_jobs( + State(state): State, + Path(project_id): Path, + Query(query): Query, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let jobs = state + .spacetime_client() + .list_open_web_project_runtime_jobs(WebProjectRuntimeJobListOpenRecordInput { + project_id, + owner_user_id: authenticated.claims().user_id().to_string(), + limit: query + .limit + .unwrap_or(WEB_PROJECT_RUNTIME_JOB_DEFAULT_LIST_LIMIT), + }) + .await + .map_err(map_web_project_client_error)?; + + Ok(json_success_body( + Some(&request_context), + WebProjectRuntimeJobListResponse { + jobs: jobs.into_iter().map(runtime_job_from_record).collect(), + }, + )) +} + +pub async fn cancel_web_project_runtime_job( + State(state): State, + Path(job_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let job = state + .spacetime_client() + .cancel_web_project_runtime_job(WebProjectRuntimeJobCancelRecordInput { + job_id, + owner_user_id: authenticated.claims().user_id().to_string(), + cancelled_at_micros: current_utc_micros(), + }) + .await + .map_err(map_web_project_client_error)?; + if job.status == "cancelled" + && let Some(preview_build_id) = job.preview_build_id.as_deref() + { + web_project_preview_runtime::cancel_preview_build_for_runtime_worker( + &state, + &job.owner_user_id, + preview_build_id, + ) + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("取消 Web 工程预览构建状态写回失败:{error}")) + })?; + } + + Ok(json_success_body( + Some(&request_context), + WebProjectRuntimeJobResponse { + job: runtime_job_from_record(job), + }, + )) +} + +pub async fn list_web_project_runtime_job_logs( + State(state): State, + Path(job_id): Path, + Query(query): Query, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let requested_limit = query + .limit + .unwrap_or(WEB_PROJECT_RUNTIME_JOB_DEFAULT_LOG_LIMIT) + .clamp(1, WEB_PROJECT_RUNTIME_JOB_MAX_LOG_LIMIT); + let logs = state + .spacetime_client() + .list_web_project_runtime_job_logs(WebProjectRuntimeJobListLogsRecordInput { + job_id, + owner_user_id: authenticated.claims().user_id().to_string(), + after_sequence: query.after_sequence, + limit: requested_limit.saturating_add(1), + }) + .await + .map_err(map_web_project_client_error)?; + let (logs, next_after_sequence, has_more) = + paginate_runtime_job_log_records(logs, requested_limit); + + Ok(json_success_body( + Some(&request_context), + WebProjectRuntimeJobLogListResponse { + logs, + next_after_sequence, + has_more, }, )) } @@ -676,7 +890,7 @@ fn snapshot_from_record(record: WebProjectSnapshotRecord) -> WebProjectSnapshot } } -fn build_from_record(record: WebProjectPreviewBuildRecord) -> WebProjectPreviewBuild { +pub(crate) fn build_from_record(record: WebProjectPreviewBuildRecord) -> WebProjectPreviewBuild { WebProjectPreviewBuild { job_id: record.job_id, project_id: record.project_id, @@ -695,6 +909,56 @@ fn build_from_record(record: WebProjectPreviewBuildRecord) -> WebProjectPreviewB } } +fn runtime_job_from_record(record: WebProjectRuntimeJobRecord) -> WebProjectRuntimeJob { + WebProjectRuntimeJob { + job_id: record.job_id, + project_id: record.project_id, + snapshot_id: record.snapshot_id, + owner_user_id: record.owner_user_id, + job_kind: runtime_job_kind_from_record(record.job_kind.as_str()), + status: runtime_job_status_from_record(record.status.as_str()), + attempt: record.attempt, + worker_id: record.worker_id, + lease_expires_at: record.lease_expires_at, + cancel_requested_at: record.cancel_requested_at, + stale_reason: record.stale_reason, + artifact_id: record.artifact_id, + preview_build_id: record.preview_build_id, + error_summary: record.error_summary, + created_at: record.created_at, + started_at: record.started_at, + finished_at: record.finished_at, + updated_at: record.updated_at, + } +} + +fn runtime_job_log_from_record(record: WebProjectRuntimeJobLogRecord) -> WebProjectRuntimeJobLog { + WebProjectRuntimeJobLog { + log_id: record.log_id, + job_id: record.job_id, + project_id: record.project_id, + owner_user_id: record.owner_user_id, + sequence: record.sequence, + level: record.level, + message: record.message, + created_at: record.created_at, + } +} + +fn paginate_runtime_job_log_records( + mut logs: Vec, + requested_limit: u32, +) -> (Vec, Option, bool) { + let has_more = logs.len() > requested_limit as usize; + logs.truncate(requested_limit as usize); + let next_after_sequence = logs.last().map(|log| log.sequence); + ( + logs.into_iter().map(runtime_job_log_from_record).collect(), + next_after_sequence, + has_more, + ) +} + fn web_project_file_to_record(file: WebProjectFile) -> WebProjectFileRecord { WebProjectFileRecord { path: file.path, @@ -727,6 +991,32 @@ fn build_status_from_record(status: &str) -> WebProjectPreviewBuildStatus { } } +fn runtime_job_kind_to_record(kind: WebProjectRuntimeJobKind) -> String { + match kind { + WebProjectRuntimeJobKind::PreviewBuild => "preview_build".to_string(), + } +} + +fn runtime_job_kind_from_record(kind: &str) -> WebProjectRuntimeJobKind { + match kind { + "preview_build" => WebProjectRuntimeJobKind::PreviewBuild, + _ => WebProjectRuntimeJobKind::PreviewBuild, + } +} + +fn runtime_job_status_from_record(status: &str) -> WebProjectRuntimeJobStatus { + match status { + "running" => WebProjectRuntimeJobStatus::Running, + "succeeded" => WebProjectRuntimeJobStatus::Succeeded, + "failed" => WebProjectRuntimeJobStatus::Failed, + "cancelled" => WebProjectRuntimeJobStatus::Cancelled, + "expired" => WebProjectRuntimeJobStatus::Expired, + "stale" => WebProjectRuntimeJobStatus::Stale, + _ => WebProjectRuntimeJobStatus::Queued, + } +} + +#[allow(dead_code)] fn spawn_preview_build_task( state: AppState, owner_user_id: String, @@ -744,6 +1034,7 @@ fn spawn_preview_build_task( }); } +#[allow(dead_code)] async fn run_preview_build_task( state: AppState, owner_user_id: String, @@ -827,6 +1118,7 @@ async fn run_preview_build_task( Ok(()) } +#[allow(dead_code)] async fn mark_preview_build_failed( state: &AppState, owner_user_id: &str, @@ -873,6 +1165,98 @@ async fn mark_preview_build_failed( } } +async fn mark_preview_build_queue_failed( + state: &AppState, + owner_user_id: &str, + build: WebProjectPreviewBuild, + error: String, +) -> WebProjectPreviewBuild { + let now = current_utc_micros(); + let mut logs = build.logs.clone(); + logs.push("构建任务入队失败".to_string()); + logs.push(error.clone()); + match state + .spacetime_client() + .update_web_project_preview_build( + spacetime_client::WebProjectPreviewBuildUpdateRecordInput { + job_id: build.job_id.clone(), + owner_user_id: owner_user_id.to_string(), + status: "failed".to_string(), + logs, + artifact_id: None, + preview_token_id: None, + preview_url: None, + error_summary: Some(error.clone()), + started_at_micros: None, + finished_at_micros: Some(now), + updated_at_micros: now, + }, + ) + .await + { + Ok(updated) => build_from_record(updated.build), + Err(update_error) => { + let mut logs = build.logs.clone(); + logs.push("构建任务入队失败".to_string()); + logs.push(error.clone()); + logs.push(format!("入队失败状态写回失败:{update_error}")); + WebProjectPreviewBuild { + status: WebProjectPreviewBuildStatus::Failed, + logs, + error_summary: Some(error), + finished_at: Some(shared_kernel::format_timestamp_micros(now)), + updated_at: shared_kernel::format_timestamp_micros(now), + ..build + } + } + } +} + +#[allow(dead_code)] +pub(crate) async fn fail_preview_build_for_runtime_worker( + state: &AppState, + owner_user_id: &str, + preview_build_id: &str, + error: &str, +) -> Result { + let mutation = state + .spacetime_client() + .get_web_project_preview_build(WebProjectPreviewBuildGetRecordInput { + job_id: preview_build_id.to_string(), + owner_user_id: owner_user_id.to_string(), + }) + .await + .map_err(|error| error.to_string())?; + let build = build_from_record(mutation.build); + let now = current_utc_micros(); + let mut logs = build.logs.clone(); + logs.push("runtime worker: 构建任务失败".to_string()); + logs.push(format!("runtime worker: {error}")); + let updated = state + .spacetime_client() + .update_web_project_preview_build( + spacetime_client::WebProjectPreviewBuildUpdateRecordInput { + job_id: build.job_id.clone(), + owner_user_id: owner_user_id.to_string(), + status: "failed".to_string(), + logs, + artifact_id: None, + preview_token_id: None, + preview_url: None, + error_summary: Some(error.to_string()), + started_at_micros: None, + finished_at_micros: Some(now), + updated_at_micros: now, + }, + ) + .await + .map_err(|error| error.to_string())?; + let failed = build_from_record(updated.build); + publish_build_event(state, &failed, failed.error_summary.as_deref()); + Ok(failed) +} + +#[allow(dead_code)] async fn invoke_runner_process( state: &AppState, build: &WebProjectPreviewBuild, @@ -968,6 +1352,7 @@ async fn invoke_runner_process( .map_err(|error| format!("runner 输出不是合法 JSON:{error}")) } +#[allow(dead_code)] fn resolve_runner_binary(state: &AppState) -> Result { if let Some(path) = state.config.web_project_runner_bin.as_ref() { return Ok(path.clone()); @@ -984,15 +1369,21 @@ fn resolve_runner_binary(state: &AppState) -> Result { .unwrap_or_else(|| PathBuf::from(exe_name))) } +#[allow(dead_code)] fn build_preview_token_id(issued_at_micros: i64) -> String { format!("wpt_{}_{}", issued_at_micros, new_uuid_simple_string()) } +#[allow(dead_code)] fn build_preview_url(base_url: &str, preview_token_id: &str) -> String { format!("{}/p/{preview_token_id}/", base_url.trim_end_matches('/')) } -fn publish_build_event(state: &AppState, build: &WebProjectPreviewBuild, message: Option<&str>) { +pub(crate) fn publish_build_event( + state: &AppState, + build: &WebProjectPreviewBuild, + message: Option<&str>, +) { let event = WebProjectPreviewBuildEvent { job_id: build.job_id.clone(), status: build.status.clone(), @@ -1012,6 +1403,7 @@ fn web_project_bad_request(message: &str, path: Option<&str>) -> AppError { })) } +#[allow(dead_code)] fn map_preview_build_slot_error( error: WebProjectPreviewBuildSlotError, project_id: &str, @@ -1088,4 +1480,139 @@ mod tests { assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert!(error.body_text().contains("目标文件已存在")); } + + #[test] + fn web_project_runtime_job_status_from_record_maps_terminal_states() { + assert_eq!( + runtime_job_status_from_record("succeeded"), + WebProjectRuntimeJobStatus::Succeeded + ); + assert_eq!( + runtime_job_status_from_record("stale"), + WebProjectRuntimeJobStatus::Stale + ); + assert_eq!( + runtime_job_status_from_record("unexpected"), + WebProjectRuntimeJobStatus::Queued + ); + } + + #[test] + fn web_project_runtime_job_response_does_not_expose_lease_token() { + let job = runtime_job_from_record(WebProjectRuntimeJobRecord { + job_id: "job-1".to_string(), + project_id: "project-1".to_string(), + snapshot_id: "snapshot-1".to_string(), + owner_user_id: "user-1".to_string(), + job_kind: "preview_build".to_string(), + status: "running".to_string(), + attempt: 1, + worker_id: Some("worker-1".to_string()), + lease_token: Some("secret-lease".to_string()), + lease_expires_at: Some("2026-06-17T00:00:00Z".to_string()), + cancel_requested_at: None, + stale_reason: None, + artifact_id: None, + preview_build_id: None, + error_summary: None, + created_at: "2026-06-17T00:00:00Z".to_string(), + started_at: Some("2026-06-17T00:00:00Z".to_string()), + finished_at: None, + updated_at: "2026-06-17T00:00:00Z".to_string(), + }); + + let value = serde_json::to_value(WebProjectRuntimeJobResponse { job }) + .expect("runtime job response should serialize"); + + assert!(value["job"].get("leaseToken").is_none()); + assert_eq!(value["job"]["status"], "running"); + } + + #[test] + fn web_project_runtime_logs_paginates_by_sequence() { + let records = vec![ + runtime_log_record(1, "hydrate snapshot"), + runtime_log_record(2, "run build"), + runtime_log_record(3, "write artifact"), + ]; + + let (logs, next_after_sequence, has_more) = + paginate_runtime_job_log_records(records, 2); + + assert_eq!( + logs.iter().map(|log| log.sequence).collect::>(), + vec![1, 2] + ); + assert_eq!( + logs.iter().map(|log| log.message.as_str()).collect::>(), + vec!["hydrate snapshot", "run build"] + ); + assert_eq!(next_after_sequence, Some(2)); + assert!(has_more); + } + + #[test] + fn preview_build_response_can_include_runtime_job() { + let build = WebProjectPreviewBuild { + job_id: "web-build-1".to_string(), + project_id: "web-project-1".to_string(), + snapshot_id: "web-snapshot-1".to_string(), + owner_user_id: "user-1".to_string(), + status: WebProjectPreviewBuildStatus::Queued, + logs: vec!["构建任务已进入队列".to_string()], + artifact_id: None, + preview_token_id: None, + preview_url: None, + error_summary: None, + created_at: "2026-06-17T00:00:00Z".to_string(), + started_at: None, + finished_at: None, + updated_at: "2026-06-17T00:00:00Z".to_string(), + }; + let runtime_job = runtime_job_from_record(WebProjectRuntimeJobRecord { + job_id: "web-runtime-job-1".to_string(), + project_id: "web-project-1".to_string(), + snapshot_id: "web-snapshot-1".to_string(), + owner_user_id: "user-1".to_string(), + job_kind: "preview_build".to_string(), + status: "queued".to_string(), + attempt: 0, + worker_id: None, + lease_token: Some("secret-lease".to_string()), + lease_expires_at: None, + cancel_requested_at: None, + stale_reason: None, + artifact_id: None, + preview_build_id: Some("web-build-1".to_string()), + error_summary: None, + created_at: "2026-06-17T00:00:00Z".to_string(), + started_at: None, + finished_at: None, + updated_at: "2026-06-17T00:00:00Z".to_string(), + }); + + let value = serde_json::to_value(WebProjectPreviewBuildResponse { + build, + runtime_job: Some(runtime_job), + }) + .expect("preview build response should serialize"); + + assert_eq!(value["build"]["jobId"], "web-build-1"); + assert_eq!(value["runtimeJob"]["jobId"], "web-runtime-job-1"); + assert_eq!(value["runtimeJob"]["previewBuildId"], "web-build-1"); + assert!(value["runtimeJob"].get("leaseToken").is_none()); + } + + fn runtime_log_record(sequence: u64, message: &str) -> WebProjectRuntimeJobLogRecord { + WebProjectRuntimeJobLogRecord { + log_id: format!("runtime-log-{sequence}"), + job_id: "web-runtime-job-1".to_string(), + project_id: "web-project-1".to_string(), + owner_user_id: "user-1".to_string(), + sequence, + level: "info".to_string(), + message: message.to_string(), + created_at: format!("2026-06-17T00:00:0{sequence}.000Z"), + } + } } diff --git a/server-rs/crates/api-server/src/web_project_preview_runtime.rs b/server-rs/crates/api-server/src/web_project_preview_runtime.rs new file mode 100644 index 00000000..b368d77a --- /dev/null +++ b/server-rs/crates/api-server/src/web_project_preview_runtime.rs @@ -0,0 +1,472 @@ +use std::{ + future::Future, + path::PathBuf, + pin::Pin, + process::Stdio, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use shared_contracts::web_project::{WebProjectPreviewBuild, WebProjectPreviewBuildStatus}; +use shared_kernel::new_uuid_simple_string; +use spacetime_client::{ + WebProjectPreviewBuildGetRecordInput, WebProjectPreviewBuildUpdateRecordInput, + WebProjectRuntimeJobRecord, WebProjectSnapshotGetRecordInput, WebProjectSnapshotRecord, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + process::Command, +}; +use web_project_runner::{ + WebProjectBuildInput, WebProjectBuildOutput, WebProjectBuildStatus, WebProjectRunnerFile, +}; + +use crate::{state::AppState, web_project}; + +const WEB_PROJECT_RUNNER_TIMEOUT: Duration = Duration::from_secs(300); + +// P2-06 先保留子进程 runner 边界;后续 Docker / gVisor / microVM 只替换 SandboxRuntime 实现。 +pub(crate) trait SandboxRuntime: Send + Sync { + fn run_preview_build<'a>( + &'a self, + state: &'a AppState, + build: &'a WebProjectPreviewBuild, + snapshot: WebProjectSnapshotRecord, + ) -> Pin> + Send + 'a>>; +} + +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct TempDirBuildRuntime; + +impl SandboxRuntime for TempDirBuildRuntime { + fn run_preview_build<'a>( + &'a self, + state: &'a AppState, + build: &'a WebProjectPreviewBuild, + snapshot: WebProjectSnapshotRecord, + ) -> Pin> + Send + 'a>> { + Box::pin(invoke_runner_process(state, build, snapshot)) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct PreviewBuildRunOutput { + pub build: WebProjectPreviewBuild, + pub runner_output: WebProjectBuildOutput, + pub started_at_micros: i64, + pub finished_at_micros: i64, +} + +#[derive(Debug, Clone)] +pub(crate) struct PreviewBuildFinishPlan { + pub succeeded: bool, + pub logs: Vec, + pub artifact_id: Option, + pub preview_token_id: Option, + pub preview_url: Option, + pub error_summary: Option, +} + +pub(crate) async fn run_preview_build_with_temp_dir_runtime( + state: &AppState, + job: &WebProjectRuntimeJobRecord, +) -> Result { + let runtime = TempDirBuildRuntime; + run_preview_build_with_runtime(state, job, &runtime).await +} + +async fn run_preview_build_with_runtime( + state: &AppState, + job: &WebProjectRuntimeJobRecord, + runtime: &R, +) -> Result { + let preview_build_id = job + .preview_build_id + .as_deref() + .ok_or_else(|| "preview_build runtime job 缺少 preview_build_id".to_string())?; + let build = get_preview_build(state, &job.owner_user_id, preview_build_id).await?; + validate_runtime_job_preview_build(job, &build)?; + if is_terminal_preview_build_status(&build.status) { + return Err(format!( + "preview build {preview_build_id} 已终态,不能重复执行" + )); + } + + let started_at_micros = current_utc_micros(); + let running_build = mark_preview_build_running(state, &job.owner_user_id, &build).await?; + let snapshot = state + .spacetime_client() + .get_web_project_snapshot(WebProjectSnapshotGetRecordInput { + project_id: job.project_id.clone(), + snapshot_id: Some(job.snapshot_id.clone()), + owner_user_id: job.owner_user_id.clone(), + }) + .await + .map_err(|error| format!("hydrate snapshot 失败:{error}"))? + .snapshot; + let runner_output = runtime + .run_preview_build(state, &running_build, snapshot) + .await?; + let finished_at_micros = current_utc_micros(); + + Ok(PreviewBuildRunOutput { + build: running_build, + runner_output, + started_at_micros, + finished_at_micros, + }) +} + +pub(crate) async fn finish_preview_build_from_runner_output( + state: &AppState, + owner_user_id: &str, + running_build: &WebProjectPreviewBuild, + started_at_micros: i64, + finished_at_micros: i64, + runner_output: &WebProjectBuildOutput, +) -> Result, String> { + let finish_plan = plan_preview_build_finish_from_runner_output( + state, + running_build, + finished_at_micros, + runner_output, + ); + let updated = state + .spacetime_client() + .update_web_project_preview_build(WebProjectPreviewBuildUpdateRecordInput { + job_id: running_build.job_id.clone(), + owner_user_id: owner_user_id.to_string(), + status: if finish_plan.succeeded { + "succeeded" + } else { + "failed" + } + .to_string(), + logs: finish_plan.logs, + artifact_id: finish_plan.artifact_id.clone(), + preview_token_id: finish_plan.preview_token_id, + preview_url: finish_plan.preview_url.clone(), + error_summary: finish_plan.error_summary.clone(), + started_at_micros: Some(started_at_micros), + finished_at_micros: Some(finished_at_micros), + updated_at_micros: finished_at_micros, + }) + .await + .map_err(|error| error.to_string())?; + let build = web_project::build_from_record(updated.build); + web_project::publish_build_event( + state, + &build, + build + .error_summary + .as_deref() + .or(Some(if finish_plan.succeeded { + "构建完成" + } else { + "构建失败" + })), + ); + + Ok(finish_plan.error_summary) +} + +pub(crate) fn plan_preview_build_finish_from_runner_output( + state: &AppState, + running_build: &WebProjectPreviewBuild, + finished_at_micros: i64, + runner_output: &WebProjectBuildOutput, +) -> PreviewBuildFinishPlan { + let succeeded = runner_output.status == WebProjectBuildStatus::Succeeded + && runner_output.artifact_id.is_some(); + let missing_artifact_error = (runner_output.status == WebProjectBuildStatus::Succeeded + && runner_output.artifact_id.is_none()) + .then(|| "web-project-runner 成功但缺少 artifact_id".to_string()); + let preview_token_id = succeeded.then(|| build_preview_token_id(finished_at_micros)); + let preview_url = preview_token_id + .as_ref() + .map(|token| build_preview_url(&state.config.web_project_preview_public_base_url, token)); + let error_summary = missing_artifact_error.or_else(|| runner_output.error_summary.clone()); + let mut logs = running_build.logs.clone(); + logs.extend(runner_output.logs.clone()); + logs.push(if succeeded { + "runtime worker: 构建完成,预览地址已生成".to_string() + } else { + "runtime worker: 构建失败".to_string() + }); + + PreviewBuildFinishPlan { + succeeded, + logs, + artifact_id: runner_output.artifact_id.clone().filter(|_| succeeded), + preview_token_id, + preview_url, + error_summary, + } +} + +pub(crate) async fn fail_preview_build_for_runtime_worker( + state: &AppState, + owner_user_id: &str, + preview_build_id: &str, + error: &str, +) -> Result { + finish_preview_build_with_terminal_status( + state, + owner_user_id, + preview_build_id, + "failed", + "runtime worker: 构建任务失败", + error, + ) + .await +} + +pub(crate) async fn cancel_preview_build_for_runtime_worker( + state: &AppState, + owner_user_id: &str, + preview_build_id: &str, +) -> Result { + finish_preview_build_with_terminal_status( + state, + owner_user_id, + preview_build_id, + "cancelled", + "runtime worker: 构建任务已取消", + "构建任务已取消", + ) + .await +} + +async fn finish_preview_build_with_terminal_status( + state: &AppState, + owner_user_id: &str, + preview_build_id: &str, + status: &str, + log_message: &str, + error: &str, +) -> Result { + let build = get_preview_build(state, owner_user_id, preview_build_id).await?; + let now = current_utc_micros(); + let mut logs = build.logs.clone(); + logs.push(log_message.to_string()); + logs.push(format!("runtime worker: {error}")); + let updated = state + .spacetime_client() + .update_web_project_preview_build(WebProjectPreviewBuildUpdateRecordInput { + job_id: build.job_id.clone(), + owner_user_id: owner_user_id.to_string(), + status: status.to_string(), + logs, + artifact_id: None, + preview_token_id: None, + preview_url: None, + error_summary: Some(error.to_string()), + started_at_micros: None, + finished_at_micros: Some(now), + updated_at_micros: now, + }) + .await + .map_err(|error| error.to_string())?; + let build = web_project::build_from_record(updated.build); + web_project::publish_build_event(state, &build, build.error_summary.as_deref()); + Ok(build) +} + +async fn mark_preview_build_running( + state: &AppState, + owner_user_id: &str, + build: &WebProjectPreviewBuild, +) -> Result { + let started_at_micros = current_utc_micros(); + let mut logs = build.logs.clone(); + logs.push("runtime worker: hydrate snapshot".to_string()); + logs.push("构建任务开始执行".to_string()); + let updated = state + .spacetime_client() + .update_web_project_preview_build(WebProjectPreviewBuildUpdateRecordInput { + job_id: build.job_id.clone(), + owner_user_id: owner_user_id.to_string(), + status: "running".to_string(), + logs, + artifact_id: None, + preview_token_id: None, + preview_url: None, + error_summary: None, + started_at_micros: Some(started_at_micros), + finished_at_micros: None, + updated_at_micros: started_at_micros, + }) + .await + .map_err(|error| error.to_string())?; + let running_build = web_project::build_from_record(updated.build); + web_project::publish_build_event(state, &running_build, Some("构建任务开始执行")); + Ok(running_build) +} + +async fn get_preview_build( + state: &AppState, + owner_user_id: &str, + preview_build_id: &str, +) -> Result { + let mutation = state + .spacetime_client() + .get_web_project_preview_build(WebProjectPreviewBuildGetRecordInput { + job_id: preview_build_id.to_string(), + owner_user_id: owner_user_id.to_string(), + }) + .await + .map_err(|error| error.to_string())?; + Ok(web_project::build_from_record(mutation.build)) +} + +fn validate_runtime_job_preview_build( + job: &WebProjectRuntimeJobRecord, + build: &WebProjectPreviewBuild, +) -> Result<(), String> { + if build.project_id != job.project_id + || build.snapshot_id != job.snapshot_id + || build.owner_user_id != job.owner_user_id + { + return Err(format!( + "runtime job {} 与 preview build {} 绑定信息不一致", + job.job_id, build.job_id + )); + } + Ok(()) +} + +fn is_terminal_preview_build_status(status: &WebProjectPreviewBuildStatus) -> bool { + matches!( + status, + WebProjectPreviewBuildStatus::Succeeded + | WebProjectPreviewBuildStatus::Failed + | WebProjectPreviewBuildStatus::Cancelled + | WebProjectPreviewBuildStatus::Expired + | WebProjectPreviewBuildStatus::Stale + ) +} + +async fn invoke_runner_process( + state: &AppState, + build: &WebProjectPreviewBuild, + snapshot: WebProjectSnapshotRecord, +) -> Result { + let runner_bin = resolve_runner_binary(state)?; + let input = WebProjectBuildInput { + job_id: build.job_id.clone(), + project_id: build.project_id.clone(), + snapshot_id: build.snapshot_id.clone(), + files: snapshot + .files + .into_iter() + .map(|file| WebProjectRunnerFile { + path: file.path, + content: file.content, + }) + .collect(), + artifact_root: state.config.web_project_artifact_root.clone(), + }; + let input_json = serde_json::to_vec(&input).map_err(|error| error.to_string())?; + let mut command = Command::new(&runner_bin); + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = command + .spawn() + .map_err(|error| format!("启动 web-project-runner 失败:{error}"))?; + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(&input_json) + .await + .map_err(|error| format!("写入 runner 输入失败:{error}"))?; + } + let mut stdout = child + .stdout + .take() + .ok_or_else(|| "web-project-runner stdout 未打开".to_string())?; + let mut stderr = child + .stderr + .take() + .ok_or_else(|| "web-project-runner stderr 未打开".to_string())?; + let stdout_task = tokio::spawn(async move { + let mut bytes = Vec::new(); + stdout + .read_to_end(&mut bytes) + .await + .map(|_| bytes) + .map_err(|error| error.to_string()) + }); + let stderr_task = tokio::spawn(async move { + let mut bytes = Vec::new(); + stderr + .read_to_end(&mut bytes) + .await + .map(|_| bytes) + .map_err(|error| error.to_string()) + }); + let status = match tokio::time::timeout(WEB_PROJECT_RUNNER_TIMEOUT, child.wait()).await { + Ok(result) => result.map_err(|error| format!("等待 runner 退出失败:{error}"))?, + Err(_) => { + let _ = child.kill().await; + let _ = child.wait().await; + let _ = stdout_task.await; + let _ = stderr_task.await; + return Err("web-project-runner 执行超时".to_string()); + } + }; + let stdout = stdout_task + .await + .map_err(|error| format!("读取 runner stdout 任务失败:{error}"))? + .map_err(|error| format!("读取 runner stdout 失败:{error}"))?; + let stderr = stderr_task + .await + .map_err(|error| format!("读取 runner stderr 任务失败:{error}"))? + .map_err(|error| format!("读取 runner stderr 失败:{error}"))?; + if !status.success() { + let stderr = String::from_utf8_lossy(&stderr); + return Ok(WebProjectBuildOutput { + job_id: build.job_id.clone(), + project_id: build.project_id.clone(), + snapshot_id: build.snapshot_id.clone(), + status: WebProjectBuildStatus::Failed, + artifact_id: None, + artifact_path: None, + error_summary: Some(format!("web-project-runner 退出失败:{}", stderr.trim())), + logs: vec![stderr.to_string()], + }); + } + serde_json::from_slice::(&stdout) + .map_err(|error| format!("runner 输出不是合法 JSON:{error}")) +} + +fn resolve_runner_binary(state: &AppState) -> Result { + if let Some(path) = state.config.web_project_runner_bin.as_ref() { + return Ok(path.clone()); + } + let current_exe = std::env::current_exe().map_err(|error| error.to_string())?; + let exe_name = if cfg!(windows) { + "web-project-runner.exe" + } else { + "web-project-runner" + }; + Ok(current_exe + .parent() + .map(|parent| parent.join(exe_name)) + .unwrap_or_else(|| PathBuf::from(exe_name))) +} + +fn build_preview_token_id(issued_at_micros: i64) -> String { + format!("wpt_{}_{}", issued_at_micros, new_uuid_simple_string()) +} + +fn build_preview_url(base_url: &str, preview_token_id: &str) -> String { + format!("{}/p/{preview_token_id}/", base_url.trim_end_matches('/')) +} + +fn current_utc_micros() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch"); + i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") +} diff --git a/server-rs/crates/api-server/src/web_project_runtime_worker.rs b/server-rs/crates/api-server/src/web_project_runtime_worker.rs new file mode 100644 index 00000000..32b5da6c --- /dev/null +++ b/server-rs/crates/api-server/src/web_project_runtime_worker.rs @@ -0,0 +1,920 @@ +use std::{future::Future, io, pin::Pin, time::Duration}; + +use shared_kernel::{build_prefixed_uuid_id, offset_datetime_to_unix_micros}; +use spacetime_client::{ + WebProjectRuntimeJobAppendLogRecordInput, WebProjectRuntimeJobClaimRecordInput, + WebProjectRuntimeJobCompletePreviewBuildRecordInput, WebProjectRuntimeJobCompleteRecordInput, + WebProjectRuntimeJobFailRecordInput, WebProjectRuntimeJobListLogsRecordInput, + WebProjectRuntimeJobLogRecord, WebProjectRuntimeJobRecord, + WebProjectRuntimeJobRenewLeaseRecordInput, +}; +use tokio::{ + task::JoinSet, + time::{Instant, sleep}, +}; +use tracing::{error, info, warn}; + +use web_project_runner::WebProjectBuildStatus; + +use crate::{ + config::WebProjectRuntimeWorkerDryRunResult, state::AppState, web_project, + web_project_preview_runtime, +}; + +const WEB_PROJECT_RUNTIME_JOB_KIND_PREVIEW_BUILD: &str = "preview_build"; +const WEB_PROJECT_RUNTIME_JOB_LOG_ID_PREFIX: &str = "web-runtime-log-"; +const WEB_PROJECT_RUNTIME_JOB_LOG_PAGE_SIZE: u32 = 200; + +pub(crate) async fn run_web_project_runtime_worker(state: AppState) -> Result<(), io::Error> { + let worker_id = state.config.web_project_runtime_worker_id.clone(); + let concurrency = state.config.web_project_runtime_worker_concurrency.max(1); + let poll_interval = state.config.web_project_runtime_worker_poll_interval; + let lease = state.config.web_project_runtime_worker_lease; + let dry_run_result = state.config.web_project_runtime_worker_dry_run_result; + let mut tasks = JoinSet::new(); + let mut shutdown = web_project_runtime_worker_shutdown_signal(); + + info!( + worker_id, + concurrency, + poll_interval_ms = poll_interval.as_millis(), + lease_seconds = lease.as_secs(), + dry_run_result = dry_run_result.as_str(), + "Web Project runtime worker 已启动" + ); + + loop { + while tasks.len() >= concurrency { + if await_worker_task_or_shutdown(&mut tasks, &mut shutdown).await { + drain_web_project_runtime_worker_tasks(&mut tasks).await; + return Ok(()); + } + } + + let available = concurrency.saturating_sub(tasks.len()).max(1); + let now_micros = current_utc_micros(); + let lease_expires_at_micros = now_micros.saturating_add(duration_micros_i64(lease)); + + let claim_jobs = state.spacetime_client().claim_web_project_runtime_jobs( + WebProjectRuntimeJobClaimRecordInput { + worker_id: worker_id.clone(), + limit: available.min(u32::MAX as usize) as u32, + lease_expires_at_micros, + claimed_at_micros: now_micros, + }, + ); + tokio::pin!(claim_jobs); + let jobs = match tokio::select! { + _ = shutdown.as_mut() => { + drain_web_project_runtime_worker_tasks(&mut tasks).await; + return Ok(()); + } + result = &mut claim_jobs => result, + } { + Ok(jobs) => jobs, + Err(error) => { + error!(error = %error, "领取 Web Project runtime job 失败,等待下一轮重试"); + if await_one_task_or_sleep_or_shutdown( + &mut tasks, + sleep(poll_interval), + &mut shutdown, + ) + .await + { + drain_web_project_runtime_worker_tasks(&mut tasks).await; + return Ok(()); + } + continue; + } + }; + + if jobs.is_empty() { + if await_one_task_or_sleep_or_shutdown(&mut tasks, sleep(poll_interval), &mut shutdown) + .await + { + drain_web_project_runtime_worker_tasks(&mut tasks).await; + return Ok(()); + } + continue; + } + + for job in jobs { + let state = state.clone(); + let worker_id = worker_id.clone(); + tasks.spawn(async move { + if let Err(error) = + process_web_project_runtime_job(state, worker_id, lease, dry_run_result, job) + .await + { + error!(error = %error, "Web Project runtime worker 执行任务失败"); + } + }); + } + } +} + +type WebProjectRuntimeWorkerShutdownSignal = Pin + Send>>; + +fn web_project_runtime_worker_shutdown_signal() -> WebProjectRuntimeWorkerShutdownSignal { + Box::pin(async { + wait_for_web_project_runtime_worker_shutdown_signal().await; + }) +} + +#[cfg(unix)] +async fn wait_for_web_project_runtime_worker_shutdown_signal() { + use tokio::signal::unix::{SignalKind, signal}; + + let mut sigterm = signal(SignalKind::terminate()).ok(); + tokio::select! { + result = tokio::signal::ctrl_c() => { + if let Err(error) = result { + warn!(error = %error, "Web Project runtime worker 监听 SIGINT 失败"); + } + } + _ = async { + if let Some(sigterm) = sigterm.as_mut() { + sigterm.recv().await; + } else { + std::future::pending::<()>().await; + } + } => {} + } +} + +#[cfg(not(unix))] +async fn wait_for_web_project_runtime_worker_shutdown_signal() { + if let Err(error) = tokio::signal::ctrl_c().await { + warn!(error = %error, "Web Project runtime worker 监听 Ctrl-C 失败"); + } +} + +async fn await_worker_task(tasks: &mut JoinSet<()>) { + if let Some(result) = tasks.join_next().await + && let Err(error) = result + { + error!(error = %error, "Web Project runtime worker 子任务 panic"); + } +} + +async fn await_worker_task_or_shutdown( + tasks: &mut JoinSet<()>, + shutdown: &mut WebProjectRuntimeWorkerShutdownSignal, +) -> bool { + tokio::select! { + _ = shutdown.as_mut() => true, + _ = await_worker_task(tasks) => false, + } +} + +async fn await_one_task_or_sleep_or_shutdown( + tasks: &mut JoinSet<()>, + sleeper: impl Future, + shutdown: &mut WebProjectRuntimeWorkerShutdownSignal, +) -> bool { + tokio::pin!(sleeper); + if tasks.is_empty() { + tokio::select! { + _ = shutdown.as_mut() => true, + _ = &mut sleeper => false, + } + } else { + tokio::select! { + _ = shutdown.as_mut() => true, + _ = &mut sleeper => false, + result = tasks.join_next() => { + if let Some(Err(error)) = result { + error!(error = %error, "Web Project runtime worker 子任务 panic"); + } + false + } + } + } +} + +async fn drain_web_project_runtime_worker_tasks(tasks: &mut JoinSet<()>) { + info!( + in_flight_jobs = tasks.len(), + "Web Project runtime worker 收到停机信号,停止领取新任务并等待当前任务完成" + ); + while !tasks.is_empty() { + await_worker_task(tasks).await; + } + info!("Web Project runtime worker 已完成优雅停机"); +} + +async fn process_web_project_runtime_job( + state: AppState, + worker_id: String, + lease: Duration, + dry_run_result: WebProjectRuntimeWorkerDryRunResult, + job: WebProjectRuntimeJobRecord, +) -> Result<(), String> { + let terminal_update = process_web_project_runtime_job_until_terminal_update( + state.clone(), + worker_id.clone(), + dry_run_result, + job.clone(), + lease, + ) + .await?; + finish_web_project_runtime_job_terminal_update(state, worker_id, job, terminal_update).await +} + +async fn process_web_project_runtime_job_until_terminal_update( + state: AppState, + worker_id: String, + dry_run_result: WebProjectRuntimeWorkerDryRunResult, + job: WebProjectRuntimeJobRecord, + lease: Duration, +) -> Result { + let heartbeat_interval = web_project_runtime_worker_heartbeat_interval(lease); + let work = plan_web_project_runtime_job_terminal_update( + state.clone(), + worker_id.clone(), + dry_run_result, + job.clone(), + ); + tokio::pin!(work); + let heartbeat = sleep(heartbeat_interval); + tokio::pin!(heartbeat); + + loop { + tokio::select! { + biased; + result = &mut work => { + let terminal_update = result?; + let renewed_job = renew_job_lease(&state, &worker_id, &job, lease).await?; + if runtime_job_has_cancel_request(&renewed_job) { + return Ok(WebProjectRuntimeWorkerTerminalUpdate::CancelRequested); + } + return Ok(terminal_update); + } + _ = &mut heartbeat => { + let renewed_job = renew_job_lease(&state, &worker_id, &job, lease).await?; + if runtime_job_has_cancel_request(&renewed_job) { + return Ok(WebProjectRuntimeWorkerTerminalUpdate::CancelRequested); + } + heartbeat.as_mut().reset(Instant::now() + heartbeat_interval); + } + } + } +} + +async fn finish_web_project_runtime_job_terminal_update( + state: AppState, + worker_id: String, + job: WebProjectRuntimeJobRecord, + terminal_update: WebProjectRuntimeWorkerTerminalUpdate, +) -> Result<(), String> { + match terminal_update { + WebProjectRuntimeWorkerTerminalUpdate::CompleteDryRun { log_message } => { + append_job_log(&state, &worker_id, &job, "info", log_message).await?; + let updated_job = complete_job(&state, &worker_id, &job, None, None).await?; + if updated_job.status == "cancelled" { + return Err("构建任务已取消".to_string()); + } + Ok(()) + } + WebProjectRuntimeWorkerTerminalUpdate::Fail { + error_summary, + fail_preview_build, + } => { + append_job_log(&state, &worker_id, &job, "error", error_summary.clone()).await?; + let updated_job = fail_job(&state, &worker_id, &job, error_summary.clone()).await?; + if updated_job.status == "cancelled" { + cancel_linked_preview_build(&state, &job).await; + return Err("构建任务已取消".to_string()); + } + if fail_preview_build { + fail_linked_preview_build(&state, &job, &error_summary).await; + } + Err(error_summary) + } + WebProjectRuntimeWorkerTerminalUpdate::FinishPreviewBuild { + run_output, + artifact_id, + } => { + append_job_log( + &state, + &worker_id, + &job, + "info", + format!("artifact: runner 已产出 artifact {artifact_id}"), + ) + .await?; + append_job_log( + &state, + &worker_id, + &job, + "info", + "preview: runtime job 完成校验通过后写入 preview token 与 URL".to_string(), + ) + .await?; + + let finish_plan = + web_project_preview_runtime::plan_preview_build_finish_from_runner_output( + &state, + &run_output.build, + run_output.finished_at_micros, + &run_output.runner_output, + ); + let finish_result = + complete_preview_build_job(&state, &worker_id, &job, &run_output, finish_plan) + .await?; + let build = web_project::build_from_record(finish_result.build.clone()); + let event_message = finish_result + .job + .stale_reason + .as_deref() + .or(build.error_summary.as_deref()) + .or(Some("构建完成")); + web_project::publish_build_event(&state, &build, event_message); + if finish_result.job.status == "stale" { + let stale_reason = finish_result + .job + .stale_reason + .unwrap_or_else(|| "job snapshot 已不是项目 active snapshot".to_string()); + return Err(stale_reason); + } + if finish_result.job.status == "cancelled" { + return Err("构建任务已取消".to_string()); + } + info!( + job_id = job.job_id, + preview_build_id = build.job_id, + artifact_id, + preview_url = build.preview_url.as_deref().unwrap_or(""), + "Web Project runtime job 已完成预览构建" + ); + Ok(()) + } + WebProjectRuntimeWorkerTerminalUpdate::FailFinishedPreviewBuild { + run_output, + error_summary, + } => { + append_job_log( + &state, + &worker_id, + &job, + "error", + "artifact: runner 未产出可用 artifact".to_string(), + ) + .await?; + let updated_job = fail_job(&state, &worker_id, &job, error_summary.clone()).await?; + if updated_job.status == "cancelled" { + cancel_linked_preview_build(&state, &job).await; + return Err("构建任务已取消".to_string()); + } + let finish_error_summary = + web_project_preview_runtime::finish_preview_build_from_runner_output( + &state, + &job.owner_user_id, + &run_output.build, + run_output.started_at_micros, + run_output.finished_at_micros, + &run_output.runner_output, + ) + .await?; + let error_summary = finish_error_summary.unwrap_or(error_summary); + Err(error_summary) + } + WebProjectRuntimeWorkerTerminalUpdate::CancelRequested => { + append_job_log( + &state, + &worker_id, + &job, + "info", + "cancel: 收到取消请求,停止当前 runtime job".to_string(), + ) + .await?; + fail_job( + &state, + &worker_id, + &job, + "构建任务已取消".to_string(), + ) + .await?; + cancel_linked_preview_build(&state, &job).await; + Err("构建任务已取消".to_string()) + } + } +} + +async fn plan_web_project_runtime_job_terminal_update( + state: AppState, + worker_id: String, + dry_run_result: WebProjectRuntimeWorkerDryRunResult, + job: WebProjectRuntimeJobRecord, +) -> Result { + append_job_log( + &state, + &worker_id, + &job, + "info", + format!( + "runtime worker 已领取任务:{},attempt={}", + job.job_kind, job.attempt + ), + ) + .await?; + + match plan_web_project_runtime_job(&job, dry_run_result) { + WebProjectRuntimeWorkerAction::ExecutePreviewBuild => { + plan_preview_build_runtime_job_terminal_update(&state, &worker_id, &job).await + } + WebProjectRuntimeWorkerAction::Complete { log_message } => { + Ok(WebProjectRuntimeWorkerTerminalUpdate::CompleteDryRun { log_message }) + } + WebProjectRuntimeWorkerAction::Fail { error_summary } => { + Ok(WebProjectRuntimeWorkerTerminalUpdate::Fail { + error_summary, + fail_preview_build: job.preview_build_id.is_some(), + }) + } + } +} + +#[derive(Debug, PartialEq, Eq)] +enum WebProjectRuntimeWorkerAction { + ExecutePreviewBuild, + Complete { log_message: String }, + Fail { error_summary: String }, +} + +enum WebProjectRuntimeWorkerTerminalUpdate { + CompleteDryRun { + log_message: String, + }, + Fail { + error_summary: String, + fail_preview_build: bool, + }, + FinishPreviewBuild { + run_output: web_project_preview_runtime::PreviewBuildRunOutput, + artifact_id: String, + }, + FailFinishedPreviewBuild { + run_output: web_project_preview_runtime::PreviewBuildRunOutput, + error_summary: String, + }, + CancelRequested, +} + +fn plan_web_project_runtime_job( + job: &WebProjectRuntimeJobRecord, + dry_run_result: WebProjectRuntimeWorkerDryRunResult, +) -> WebProjectRuntimeWorkerAction { + if job.job_kind != WEB_PROJECT_RUNTIME_JOB_KIND_PREVIEW_BUILD { + return WebProjectRuntimeWorkerAction::Fail { + error_summary: format!("暂不支持的 Web Project runtime job 类型:{}", job.job_kind), + }; + } + if job.preview_build_id.is_some() { + return WebProjectRuntimeWorkerAction::ExecutePreviewBuild; + } + + match dry_run_result { + WebProjectRuntimeWorkerDryRunResult::Succeed if job.preview_build_id.is_none() => { + WebProjectRuntimeWorkerAction::Complete { + log_message: "P2-05 dry-run worker 已完成无预览构建绑定的 runtime job".to_string(), + } + } + WebProjectRuntimeWorkerDryRunResult::Succeed => WebProjectRuntimeWorkerAction::Fail { + error_summary: "P2-05 dry-run worker 不生成预览产物,不能完成 preview build 任务" + .to_string(), + }, + WebProjectRuntimeWorkerDryRunResult::Fail => WebProjectRuntimeWorkerAction::Fail { + error_summary: "P2-05 dry-run worker 尚未接入 SandboxRuntime,任务已按预期失败" + .to_string(), + }, + } +} + +async fn plan_preview_build_runtime_job_terminal_update( + state: &AppState, + worker_id: &str, + job: &WebProjectRuntimeJobRecord, +) -> Result { + append_job_log( + state, + worker_id, + job, + "info", + "hydrate: 开始读取 Web Project snapshot".to_string(), + ) + .await?; + append_job_log( + state, + worker_id, + job, + "info", + "build: 开始调用 TempDirBuildRuntime".to_string(), + ) + .await?; + + let run_output = match web_project_preview_runtime::run_preview_build_with_temp_dir_runtime( + state, job, + ) + .await + { + Ok(output) => output, + Err(error) => { + return Ok(WebProjectRuntimeWorkerTerminalUpdate::Fail { + error_summary: error, + fail_preview_build: job.preview_build_id.is_some(), + }); + } + }; + + if run_output.runner_output.status != WebProjectBuildStatus::Succeeded + || run_output.runner_output.artifact_id.is_none() + { + let error_summary = run_output + .runner_output + .error_summary + .clone() + .unwrap_or_else(|| "web-project-runner 构建失败,但未返回错误摘要".to_string()); + return Ok( + WebProjectRuntimeWorkerTerminalUpdate::FailFinishedPreviewBuild { + run_output, + error_summary, + }, + ); + } + + let artifact_id = run_output + .runner_output + .artifact_id + .clone() + .expect("artifact_id was checked before success path"); + Ok(WebProjectRuntimeWorkerTerminalUpdate::FinishPreviewBuild { + run_output, + artifact_id, + }) +} + +async fn fail_linked_preview_build( + state: &AppState, + job: &WebProjectRuntimeJobRecord, + error_summary: &str, +) { + if let Some(preview_build_id) = job.preview_build_id.as_deref() + && let Err(error) = web_project_preview_runtime::fail_preview_build_for_runtime_worker( + state, + &job.owner_user_id, + preview_build_id, + error_summary, + ) + .await + { + warn!( + job_id = job.job_id, + preview_build_id, + error = %error, + "runtime job 已失败,但 linked preview build 失败状态写回失败" + ); + } +} + +async fn cancel_linked_preview_build(state: &AppState, job: &WebProjectRuntimeJobRecord) { + if let Some(preview_build_id) = job.preview_build_id.as_deref() + && let Err(error) = web_project_preview_runtime::cancel_preview_build_for_runtime_worker( + state, + &job.owner_user_id, + preview_build_id, + ) + .await + { + warn!( + job_id = job.job_id, + preview_build_id, + error = %error, + "runtime job 已取消,但 linked preview build 取消状态写回失败" + ); + } +} + +async fn complete_job( + state: &AppState, + worker_id: &str, + job: &WebProjectRuntimeJobRecord, + artifact_id: Option, + preview_build_id: Option, +) -> Result { + state + .spacetime_client() + .complete_web_project_runtime_job(WebProjectRuntimeJobCompleteRecordInput { + job_id: job.job_id.clone(), + worker_id: worker_id.to_string(), + lease_token: require_job_lease_token(job)?, + artifact_id, + preview_build_id, + completed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| error.to_string()) +} + +async fn complete_preview_build_job( + state: &AppState, + worker_id: &str, + job: &WebProjectRuntimeJobRecord, + run_output: &web_project_preview_runtime::PreviewBuildRunOutput, + finish_plan: web_project_preview_runtime::PreviewBuildFinishPlan, +) -> Result { + if !finish_plan.succeeded { + return Err("preview build finish plan 不是 succeeded 状态".to_string()); + } + let preview_build_id = job + .preview_build_id + .as_deref() + .ok_or_else(|| "preview_build runtime job 缺少 preview_build_id".to_string())?; + state + .spacetime_client() + .complete_web_project_preview_build_runtime_job( + WebProjectRuntimeJobCompletePreviewBuildRecordInput { + job_id: job.job_id.clone(), + worker_id: worker_id.to_string(), + lease_token: require_job_lease_token(job)?, + preview_build_id: preview_build_id.to_string(), + artifact_id: finish_plan + .artifact_id + .ok_or_else(|| "preview build 成功结果缺少 artifact_id".to_string())?, + preview_token_id: finish_plan + .preview_token_id + .ok_or_else(|| "preview build 成功结果缺少 preview_token_id".to_string())?, + preview_url: finish_plan + .preview_url + .ok_or_else(|| "preview build 成功结果缺少 preview_url".to_string())?, + logs: finish_plan.logs, + started_at_micros: Some(run_output.started_at_micros), + finished_at_micros: run_output.finished_at_micros, + }, + ) + .await + .map_err(|error| error.to_string()) +} + +async fn fail_job( + state: &AppState, + worker_id: &str, + job: &WebProjectRuntimeJobRecord, + error_summary: String, +) -> Result { + state + .spacetime_client() + .fail_web_project_runtime_job(WebProjectRuntimeJobFailRecordInput { + job_id: job.job_id.clone(), + worker_id: worker_id.to_string(), + lease_token: require_job_lease_token(job)?, + error_summary, + failed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| error.to_string()) +} + +async fn renew_job_lease( + state: &AppState, + worker_id: &str, + job: &WebProjectRuntimeJobRecord, + lease: Duration, +) -> Result { + let now_micros = current_utc_micros(); + state + .spacetime_client() + .renew_web_project_runtime_job_lease(WebProjectRuntimeJobRenewLeaseRecordInput { + job_id: job.job_id.clone(), + worker_id: worker_id.to_string(), + lease_token: require_job_lease_token(job)?, + lease_expires_at_micros: now_micros.saturating_add(duration_micros_i64(lease)), + renewed_at_micros: now_micros, + }) + .await + .map_err(|error| error.to_string()) +} + +async fn append_job_log( + state: &AppState, + worker_id: &str, + job: &WebProjectRuntimeJobRecord, + level: &str, + message: String, +) -> Result { + let lease_token = require_job_lease_token(job)?; + let sequence = next_job_log_sequence(state, job).await?; + state + .spacetime_client() + .append_web_project_runtime_job_log(WebProjectRuntimeJobAppendLogRecordInput { + log_id: build_prefixed_uuid_id(WEB_PROJECT_RUNTIME_JOB_LOG_ID_PREFIX), + job_id: job.job_id.clone(), + owner_user_id: job.owner_user_id.clone(), + sequence, + level: level.to_string(), + message, + worker_id: Some(worker_id.to_string()), + lease_token: Some(lease_token), + created_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| error.to_string()) +} + +async fn next_job_log_sequence( + state: &AppState, + job: &WebProjectRuntimeJobRecord, +) -> Result { + let mut after_sequence = None; + let mut max_sequence = 0_u64; + + loop { + let logs = state + .spacetime_client() + .list_web_project_runtime_job_logs(WebProjectRuntimeJobListLogsRecordInput { + job_id: job.job_id.clone(), + owner_user_id: job.owner_user_id.clone(), + after_sequence, + limit: WEB_PROJECT_RUNTIME_JOB_LOG_PAGE_SIZE, + }) + .await + .map_err(|error| error.to_string())?; + if logs.is_empty() { + break; + } + if let Some(last_sequence) = max_log_sequence(&logs) { + max_sequence = max_sequence.max(last_sequence); + } + if logs.len() < WEB_PROJECT_RUNTIME_JOB_LOG_PAGE_SIZE as usize { + break; + } + after_sequence = Some(max_sequence); + } + + Ok(max_sequence.saturating_add(1)) +} + +fn max_log_sequence(logs: &[WebProjectRuntimeJobLogRecord]) -> Option { + logs.iter().map(|log| log.sequence).max() +} + +fn require_job_lease_token(job: &WebProjectRuntimeJobRecord) -> Result { + job.lease_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| format!("web_project_runtime_job {} 缺少 lease token", job.job_id)) +} + +fn runtime_job_has_cancel_request(job: &WebProjectRuntimeJobRecord) -> bool { + job.status == "cancelled" || job.cancel_requested_at.is_some() +} + +fn web_project_runtime_worker_heartbeat_interval(lease: Duration) -> Duration { + let heartbeat_millis = (lease.as_millis() / 3).clamp(250, 30_000) as u64; + Duration::from_millis(heartbeat_millis) +} + +fn duration_micros_i64(duration: Duration) -> i64 { + duration.as_micros().min(i64::MAX as u128) as i64 +} + +fn current_utc_micros() -> i64 { + offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn web_project_runtime_worker_heartbeat_interval_is_bounded() { + assert_eq!( + web_project_runtime_worker_heartbeat_interval(Duration::from_millis(300)), + Duration::from_millis(250) + ); + assert_eq!( + web_project_runtime_worker_heartbeat_interval(Duration::from_secs(9)), + Duration::from_secs(3) + ); + assert_eq!( + web_project_runtime_worker_heartbeat_interval(Duration::from_secs(300)), + Duration::from_secs(30) + ); + } + + #[test] + fn web_project_runtime_worker_requires_claimed_job_lease_token() { + let job = runtime_job_fixture(None, None); + + let error = require_job_lease_token(&job).expect_err("missing token should fail"); + + assert!(error.contains("缺少 lease token")); + } + + #[test] + fn web_project_runtime_worker_plans_success_for_unlinked_dry_run_job() { + let job = runtime_job_fixture(Some("lease-1"), None); + + let action = + plan_web_project_runtime_job(&job, WebProjectRuntimeWorkerDryRunResult::Succeed); + + assert!(matches!( + action, + WebProjectRuntimeWorkerAction::Complete { .. } + )); + } + + #[test] + fn web_project_runtime_worker_routes_linked_preview_build_to_runner() { + let job = runtime_job_fixture(Some("lease-1"), Some("web-build-1")); + + let action = + plan_web_project_runtime_job(&job, WebProjectRuntimeWorkerDryRunResult::Succeed); + + assert_eq!(action, WebProjectRuntimeWorkerAction::ExecutePreviewBuild); + } + + #[test] + fn web_project_runtime_worker_plans_configured_dry_run_failure() { + let job = runtime_job_fixture(Some("lease-1"), None); + + let action = plan_web_project_runtime_job(&job, WebProjectRuntimeWorkerDryRunResult::Fail); + + assert_eq!( + action, + WebProjectRuntimeWorkerAction::Fail { + error_summary: "P2-05 dry-run worker 尚未接入 SandboxRuntime,任务已按预期失败" + .to_string() + } + ); + } + + #[test] + fn web_project_runtime_worker_rejects_unknown_job_kind() { + let mut job = runtime_job_fixture(Some("lease-1"), None); + job.job_kind = "unknown".to_string(); + + let action = + plan_web_project_runtime_job(&job, WebProjectRuntimeWorkerDryRunResult::Succeed); + + assert_eq!( + action, + WebProjectRuntimeWorkerAction::Fail { + error_summary: "暂不支持的 Web Project runtime job 类型:unknown".to_string() + } + ); + } + + #[test] + fn web_project_runtime_worker_uses_next_log_sequence_after_existing_logs() { + let logs = vec![ + runtime_job_log_fixture(1), + runtime_job_log_fixture(4), + runtime_job_log_fixture(2), + ]; + + assert_eq!(max_log_sequence(&logs).unwrap_or_default() + 1, 5); + } + + fn runtime_job_fixture( + lease_token: Option<&str>, + preview_build_id: Option<&str>, + ) -> WebProjectRuntimeJobRecord { + WebProjectRuntimeJobRecord { + job_id: "web-runtime-job-1".to_string(), + project_id: "web-project-1".to_string(), + snapshot_id: "web-snapshot-1".to_string(), + owner_user_id: "user-1".to_string(), + job_kind: WEB_PROJECT_RUNTIME_JOB_KIND_PREVIEW_BUILD.to_string(), + status: "running".to_string(), + attempt: 1, + worker_id: Some("worker-a".to_string()), + lease_token: lease_token.map(ToOwned::to_owned), + lease_expires_at: Some("2026-06-17T00:00:00Z".to_string()), + cancel_requested_at: None, + stale_reason: None, + artifact_id: None, + preview_build_id: preview_build_id.map(ToOwned::to_owned), + error_summary: None, + created_at: "2026-06-17T00:00:00Z".to_string(), + started_at: Some("2026-06-17T00:00:00Z".to_string()), + finished_at: None, + updated_at: "2026-06-17T00:00:00Z".to_string(), + } + } + + fn runtime_job_log_fixture(sequence: u64) -> WebProjectRuntimeJobLogRecord { + WebProjectRuntimeJobLogRecord { + log_id: format!("log-{sequence}"), + job_id: "web-runtime-job-1".to_string(), + project_id: "web-project-1".to_string(), + owner_user_id: "user-1".to_string(), + sequence, + level: "info".to_string(), + message: "log".to_string(), + created_at: "2026-06-17T00:00:00Z".to_string(), + } + } +} diff --git a/server-rs/crates/shared-contracts/src/web_project.rs b/server-rs/crates/shared-contracts/src/web_project.rs index a4072c8c..a35c9c86 100644 --- a/server-rs/crates/shared-contracts/src/web_project.rs +++ b/server-rs/crates/shared-contracts/src/web_project.rs @@ -14,6 +14,24 @@ pub enum WebProjectPreviewBuildStatus { Stale, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WebProjectRuntimeJobKind { + PreviewBuild, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum WebProjectRuntimeJobStatus { + Queued, + Running, + Succeeded, + Failed, + Cancelled, + Expired, + Stale, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WebProject { @@ -96,6 +114,42 @@ pub struct WebProjectPreviewBuild { pub updated_at: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebProjectRuntimeJob { + pub job_id: String, + pub project_id: String, + pub snapshot_id: String, + pub owner_user_id: String, + pub job_kind: WebProjectRuntimeJobKind, + pub status: WebProjectRuntimeJobStatus, + pub attempt: u32, + pub worker_id: Option, + pub lease_expires_at: Option, + pub cancel_requested_at: Option, + pub stale_reason: Option, + pub artifact_id: Option, + pub preview_build_id: Option, + pub error_summary: Option, + pub created_at: String, + pub started_at: Option, + pub finished_at: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebProjectRuntimeJobLog { + pub log_id: String, + pub job_id: String, + pub project_id: String, + pub owner_user_id: String, + pub sequence: u64, + pub level: String, + pub message: String, + pub created_at: String, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WebProjectPreviewBuildEvent { @@ -136,4 +190,33 @@ pub struct WebProjectSnapshotResponse { #[serde(rename_all = "camelCase")] pub struct WebProjectPreviewBuildResponse { pub build: WebProjectPreviewBuild, + pub runtime_job: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebProjectRuntimeJobCreateRequest { + pub snapshot_id: Option, + pub job_kind: WebProjectRuntimeJobKind, + pub preview_build_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebProjectRuntimeJobResponse { + pub job: WebProjectRuntimeJob, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebProjectRuntimeJobListResponse { + pub jobs: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebProjectRuntimeJobLogListResponse { + pub logs: Vec, + pub next_after_sequence: Option, + pub has_more: bool, } diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 1e417dd6..441b0d2b 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -105,9 +105,17 @@ pub use mapper::{ WebProjectFileRecord, WebProjectGetRecordInput, WebProjectPreviewBuildCreateRecordInput, WebProjectPreviewBuildGetRecordInput, WebProjectPreviewBuildMutationRecord, WebProjectPreviewBuildRecord, WebProjectPreviewBuildTokenGetRecordInput, - WebProjectPreviewBuildUpdateRecordInput, WebProjectRecord, WebProjectSnapshotGetRecordInput, - WebProjectSnapshotMutationRecord, WebProjectSnapshotRecord, WebProjectSnapshotSaveRecordInput, - WoodenFishActionRequest, WoodenFishActionResponse, + WebProjectPreviewBuildUpdateRecordInput, WebProjectRecord, + WebProjectRuntimeJobAppendLogRecordInput, WebProjectRuntimeJobCancelRecordInput, + WebProjectRuntimeJobClaimRecordInput, WebProjectRuntimeJobCompletePreviewBuildRecordInput, + WebProjectRuntimeJobCompleteRecordInput, WebProjectRuntimeJobCreateRecordInput, + WebProjectRuntimeJobExpireRecordInput, WebProjectRuntimeJobFailRecordInput, + WebProjectRuntimeJobGetRecordInput, WebProjectRuntimeJobListLogsRecordInput, + WebProjectRuntimeJobListOpenRecordInput, WebProjectRuntimeJobLogRecord, + WebProjectRuntimeJobPreviewBuildMutationRecord, WebProjectRuntimeJobRecord, + WebProjectRuntimeJobRenewLeaseRecordInput, WebProjectRuntimeJobStaleRecordInput, + WebProjectSnapshotGetRecordInput, WebProjectSnapshotMutationRecord, WebProjectSnapshotRecord, + WebProjectSnapshotSaveRecordInput, WoodenFishActionRequest, WoodenFishActionResponse, WoodenFishActionType, WoodenFishAudioAsset, WoodenFishCheckpointRunRequest, WoodenFishDraftResponse, WoodenFishFinishRunRequest, WoodenFishGalleryCardResponse, WoodenFishGalleryDetailResponse, WoodenFishGalleryResponse, WoodenFishGenerationStatus, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index b785e2c7..25824a35 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -159,8 +159,16 @@ pub use self::web_project::{ WebProjectPreviewBuildCreateRecordInput, WebProjectPreviewBuildGetRecordInput, WebProjectPreviewBuildMutationRecord, WebProjectPreviewBuildRecord, WebProjectPreviewBuildTokenGetRecordInput, WebProjectPreviewBuildUpdateRecordInput, - WebProjectRecord, WebProjectSnapshotGetRecordInput, WebProjectSnapshotMutationRecord, - WebProjectSnapshotRecord, WebProjectSnapshotSaveRecordInput, + WebProjectRecord, WebProjectRuntimeJobAppendLogRecordInput, + WebProjectRuntimeJobCancelRecordInput, WebProjectRuntimeJobClaimRecordInput, + WebProjectRuntimeJobCompletePreviewBuildRecordInput, WebProjectRuntimeJobCompleteRecordInput, + WebProjectRuntimeJobCreateRecordInput, WebProjectRuntimeJobExpireRecordInput, + WebProjectRuntimeJobFailRecordInput, WebProjectRuntimeJobGetRecordInput, + WebProjectRuntimeJobListLogsRecordInput, WebProjectRuntimeJobListOpenRecordInput, + WebProjectRuntimeJobLogRecord, WebProjectRuntimeJobPreviewBuildMutationRecord, + WebProjectRuntimeJobRecord, WebProjectRuntimeJobRenewLeaseRecordInput, + WebProjectRuntimeJobStaleRecordInput, WebProjectSnapshotGetRecordInput, + WebProjectSnapshotMutationRecord, WebProjectSnapshotRecord, WebProjectSnapshotSaveRecordInput, }; pub use self::wooden_fish::{ WoodenFishActionRequest, WoodenFishActionResponse, WoodenFishActionType, WoodenFishAudioAsset, @@ -300,7 +308,10 @@ pub(crate) use self::visual_novel::{ }; pub(crate) use self::web_project::{ map_web_project_preview_build_procedure_result, map_web_project_procedure_result, - map_web_project_snapshot_procedure_result, + map_web_project_runtime_job_list_result, map_web_project_runtime_job_log_list_result, + map_web_project_runtime_job_log_procedure_result, + map_web_project_runtime_job_preview_build_procedure_result, + map_web_project_runtime_job_procedure_result, map_web_project_snapshot_procedure_result, }; pub(crate) use self::wooden_fish::{ map_wooden_fish_agent_session_procedure_result, map_wooden_fish_gallery_card_view_row, diff --git a/server-rs/crates/spacetime-client/src/mapper/web_project.rs b/server-rs/crates/spacetime-client/src/mapper/web_project.rs index 63ca3530..305fbf1f 100644 --- a/server-rs/crates/spacetime-client/src/mapper/web_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/web_project.rs @@ -52,6 +52,41 @@ pub struct WebProjectPreviewBuildRecord { pub updated_at: String, } +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WebProjectRuntimeJobRecord { + pub job_id: String, + pub project_id: String, + pub snapshot_id: String, + pub owner_user_id: String, + pub job_kind: String, + pub status: String, + pub attempt: u32, + pub worker_id: Option, + pub lease_token: Option, + pub lease_expires_at: Option, + pub cancel_requested_at: Option, + pub stale_reason: Option, + pub artifact_id: Option, + pub preview_build_id: Option, + pub error_summary: Option, + pub created_at: String, + pub started_at: Option, + pub finished_at: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WebProjectRuntimeJobLogRecord { + pub log_id: String, + pub job_id: String, + pub project_id: String, + pub owner_user_id: String, + pub sequence: u64, + pub level: String, + pub message: String, + pub created_at: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct WebProjectCreateRecordInput { pub project_id: String, @@ -122,6 +157,123 @@ pub struct WebProjectPreviewBuildUpdateRecordInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebProjectRuntimeJobCreateRecordInput { + pub job_id: String, + pub project_id: String, + pub snapshot_id: String, + pub owner_user_id: String, + pub job_kind: String, + pub preview_build_id: Option, + pub now_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebProjectRuntimeJobGetRecordInput { + pub job_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebProjectRuntimeJobListOpenRecordInput { + pub project_id: String, + pub owner_user_id: String, + pub limit: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebProjectRuntimeJobClaimRecordInput { + pub worker_id: String, + pub limit: u32, + pub lease_expires_at_micros: i64, + pub claimed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebProjectRuntimeJobRenewLeaseRecordInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub lease_expires_at_micros: i64, + pub renewed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebProjectRuntimeJobCompleteRecordInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub artifact_id: Option, + pub preview_build_id: Option, + pub completed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebProjectRuntimeJobCompletePreviewBuildRecordInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub preview_build_id: String, + pub artifact_id: String, + pub preview_token_id: String, + pub preview_url: String, + pub logs: Vec, + pub started_at_micros: Option, + pub finished_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebProjectRuntimeJobFailRecordInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub error_summary: String, + pub failed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebProjectRuntimeJobCancelRecordInput { + pub job_id: String, + pub owner_user_id: String, + pub cancelled_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebProjectRuntimeJobStaleRecordInput { + pub job_id: String, + pub owner_user_id: String, + pub stale_reason: String, + pub stale_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebProjectRuntimeJobExpireRecordInput { + pub job_id: String, + pub owner_user_id: String, + pub expired_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebProjectRuntimeJobAppendLogRecordInput { + pub log_id: String, + pub job_id: String, + pub owner_user_id: String, + pub sequence: u64, + pub level: String, + pub message: String, + pub worker_id: Option, + pub lease_token: Option, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebProjectRuntimeJobListLogsRecordInput { + pub job_id: String, + pub owner_user_id: String, + pub after_sequence: Option, + pub limit: u32, +} + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct WebProjectSnapshotMutationRecord { pub project: WebProjectRecord, @@ -134,6 +286,13 @@ pub struct WebProjectPreviewBuildMutationRecord { pub build: WebProjectPreviewBuildRecord, } +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WebProjectRuntimeJobPreviewBuildMutationRecord { + pub project: WebProjectRecord, + pub build: WebProjectPreviewBuildRecord, + pub job: WebProjectRuntimeJobRecord, +} + impl From for crate::module_bindings::WebProjectFileSnapshot { fn from(input: WebProjectFileRecord) -> Self { Self { @@ -262,6 +421,188 @@ impl From } } +impl From + for crate::module_bindings::WebProjectRuntimeJobCreateInput +{ + fn from(input: WebProjectRuntimeJobCreateRecordInput) -> Self { + Self { + job_id: input.job_id, + project_id: input.project_id, + snapshot_id: input.snapshot_id, + owner_user_id: input.owner_user_id, + job_kind: input.job_kind, + preview_build_id: input.preview_build_id, + now_micros: input.now_micros, + } + } +} + +impl From + for crate::module_bindings::WebProjectRuntimeJobGetInput +{ + fn from(input: WebProjectRuntimeJobGetRecordInput) -> Self { + Self { + job_id: input.job_id, + owner_user_id: input.owner_user_id, + } + } +} + +impl From + for crate::module_bindings::WebProjectRuntimeJobListOpenInput +{ + fn from(input: WebProjectRuntimeJobListOpenRecordInput) -> Self { + Self { + project_id: input.project_id, + owner_user_id: input.owner_user_id, + limit: input.limit, + } + } +} + +impl From + for crate::module_bindings::WebProjectRuntimeJobClaimInput +{ + fn from(input: WebProjectRuntimeJobClaimRecordInput) -> Self { + Self { + worker_id: input.worker_id, + limit: input.limit, + lease_expires_at_micros: input.lease_expires_at_micros, + claimed_at_micros: input.claimed_at_micros, + } + } +} + +impl From + for crate::module_bindings::WebProjectRuntimeJobRenewLeaseInput +{ + fn from(input: WebProjectRuntimeJobRenewLeaseRecordInput) -> Self { + Self { + job_id: input.job_id, + worker_id: input.worker_id, + lease_token: input.lease_token, + lease_expires_at_micros: input.lease_expires_at_micros, + renewed_at_micros: input.renewed_at_micros, + } + } +} + +impl From + for crate::module_bindings::WebProjectRuntimeJobCompleteInput +{ + fn from(input: WebProjectRuntimeJobCompleteRecordInput) -> Self { + Self { + job_id: input.job_id, + worker_id: input.worker_id, + lease_token: input.lease_token, + artifact_id: input.artifact_id, + preview_build_id: input.preview_build_id, + completed_at_micros: input.completed_at_micros, + } + } +} + +impl From + for crate::module_bindings::WebProjectRuntimeJobCompletePreviewBuildInput +{ + fn from(input: WebProjectRuntimeJobCompletePreviewBuildRecordInput) -> Self { + Self { + job_id: input.job_id, + worker_id: input.worker_id, + lease_token: input.lease_token, + preview_build_id: input.preview_build_id, + artifact_id: input.artifact_id, + preview_token_id: input.preview_token_id, + preview_url: input.preview_url, + logs: input.logs, + started_at_micros: input.started_at_micros, + finished_at_micros: input.finished_at_micros, + } + } +} + +impl From + for crate::module_bindings::WebProjectRuntimeJobFailInput +{ + fn from(input: WebProjectRuntimeJobFailRecordInput) -> Self { + Self { + job_id: input.job_id, + worker_id: input.worker_id, + lease_token: input.lease_token, + error_summary: input.error_summary, + failed_at_micros: input.failed_at_micros, + } + } +} + +impl From + for crate::module_bindings::WebProjectRuntimeJobCancelInput +{ + fn from(input: WebProjectRuntimeJobCancelRecordInput) -> Self { + Self { + job_id: input.job_id, + owner_user_id: input.owner_user_id, + cancelled_at_micros: input.cancelled_at_micros, + } + } +} + +impl From + for crate::module_bindings::WebProjectRuntimeJobStaleInput +{ + fn from(input: WebProjectRuntimeJobStaleRecordInput) -> Self { + Self { + job_id: input.job_id, + owner_user_id: input.owner_user_id, + stale_reason: input.stale_reason, + stale_at_micros: input.stale_at_micros, + } + } +} + +impl From + for crate::module_bindings::WebProjectRuntimeJobExpireInput +{ + fn from(input: WebProjectRuntimeJobExpireRecordInput) -> Self { + Self { + job_id: input.job_id, + owner_user_id: input.owner_user_id, + expired_at_micros: input.expired_at_micros, + } + } +} + +impl From + for crate::module_bindings::WebProjectRuntimeJobAppendLogInput +{ + fn from(input: WebProjectRuntimeJobAppendLogRecordInput) -> Self { + Self { + log_id: input.log_id, + job_id: input.job_id, + owner_user_id: input.owner_user_id, + sequence: input.sequence, + level: input.level, + message: input.message, + worker_id: input.worker_id, + lease_token: input.lease_token, + created_at_micros: input.created_at_micros, + } + } +} + +impl From + for crate::module_bindings::WebProjectRuntimeJobListLogsInput +{ + fn from(input: WebProjectRuntimeJobListLogsRecordInput) -> Self { + Self { + job_id: input.job_id, + owner_user_id: input.owner_user_id, + after_sequence: input.after_sequence, + limit: input.limit, + } + } +} + pub(crate) fn map_web_project_procedure_result( result: WebProjectProcedureResult, ) -> Result { @@ -313,6 +654,87 @@ pub(crate) fn map_web_project_preview_build_procedure_result( Ok(WebProjectPreviewBuildMutationRecord { project, build }) } +pub(crate) fn map_web_project_runtime_job_procedure_result( + result: WebProjectRuntimeJobProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + result + .job + .map(map_web_project_runtime_job_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("Web Project runtime job")) +} + +pub(crate) fn map_web_project_runtime_job_preview_build_procedure_result( + result: WebProjectRuntimeJobPreviewBuildProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let project = result + .project + .map(map_web_project_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("Web 工程"))?; + let build = result + .build + .map(map_web_project_preview_build_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("Web 工程预览构建"))?; + let job = result + .job + .map(map_web_project_runtime_job_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("Web Project runtime job"))?; + + Ok(WebProjectRuntimeJobPreviewBuildMutationRecord { + project, + build, + job, + }) +} + +pub(crate) fn map_web_project_runtime_job_list_result( + result: WebProjectRuntimeJobProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .jobs + .into_iter() + .map(map_web_project_runtime_job_snapshot) + .collect()) +} + +pub(crate) fn map_web_project_runtime_job_log_procedure_result( + result: WebProjectRuntimeJobProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + result + .log + .map(map_web_project_runtime_job_log_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("Web Project runtime job 日志")) +} + +pub(crate) fn map_web_project_runtime_job_log_list_result( + result: WebProjectRuntimeJobProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .logs + .into_iter() + .map(map_web_project_runtime_job_log_snapshot) + .collect()) +} + fn map_web_project_snapshot(snapshot: WebProjectProjectSnapshot) -> WebProjectRecord { WebProjectRecord { project_id: snapshot.project_id, @@ -361,6 +783,51 @@ fn map_web_project_preview_build_snapshot( } } +fn map_web_project_runtime_job_snapshot( + snapshot: WebProjectRuntimeJobSnapshot, +) -> WebProjectRuntimeJobRecord { + WebProjectRuntimeJobRecord { + job_id: snapshot.job_id, + project_id: snapshot.project_id, + snapshot_id: snapshot.snapshot_id, + owner_user_id: snapshot.owner_user_id, + job_kind: snapshot.job_kind, + status: snapshot.status, + attempt: snapshot.attempt, + worker_id: snapshot.worker_id, + lease_token: snapshot.lease_token, + lease_expires_at: snapshot + .lease_expires_at_micros + .map(format_timestamp_micros), + cancel_requested_at: snapshot + .cancel_requested_at_micros + .map(format_timestamp_micros), + stale_reason: snapshot.stale_reason, + artifact_id: snapshot.artifact_id, + preview_build_id: snapshot.preview_build_id, + error_summary: snapshot.error_summary, + created_at: format_timestamp_micros(snapshot.created_at_micros), + started_at: snapshot.started_at_micros.map(format_timestamp_micros), + finished_at: snapshot.finished_at_micros.map(format_timestamp_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +fn map_web_project_runtime_job_log_snapshot( + snapshot: WebProjectRuntimeJobLogSnapshot, +) -> WebProjectRuntimeJobLogRecord { + WebProjectRuntimeJobLogRecord { + log_id: snapshot.log_id, + job_id: snapshot.job_id, + project_id: snapshot.project_id, + owner_user_id: snapshot.owner_user_id, + sequence: snapshot.sequence, + level: snapshot.level, + message: snapshot.message, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + #[cfg(test)] mod tests { use super::*; @@ -380,4 +847,102 @@ mod tests { "无权访问该 Web 工程" ); } + + #[test] + fn maps_runtime_job_log_list_in_sequence_order_from_module_result() { + let result = WebProjectRuntimeJobProcedureResult { + ok: true, + job: None, + jobs: Vec::new(), + log: None, + logs: vec![WebProjectRuntimeJobLogSnapshot { + log_id: "log-1".to_string(), + job_id: "job-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + sequence: 2, + level: "info".to_string(), + message: "构建完成".to_string(), + created_at_micros: 1_700_000_000_000_000, + }], + error_message: None, + }; + + let logs = map_web_project_runtime_job_log_list_result(result) + .expect("runtime job logs should map"); + + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].sequence, 2); + assert_eq!(logs[0].message, "构建完成"); + } + + #[test] + fn maps_runtime_job_preview_build_combo_result() { + let result = WebProjectRuntimeJobPreviewBuildProcedureResult { + ok: true, + project: Some(WebProjectProjectSnapshot { + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + title: "测试工程".to_string(), + template_key: "react-vite-ts-static".to_string(), + active_snapshot_id: "snapshot-2".to_string(), + active_preview_build_id: Some("build-2".to_string()), + created_at_micros: 1_700_000_000_000_000, + updated_at_micros: 1_700_000_010_000_000, + }), + build: Some(WebProjectPreviewBuildSnapshot { + job_id: "build-1".to_string(), + project_id: "project-1".to_string(), + snapshot_id: "snapshot-1".to_string(), + owner_user_id: "user-1".to_string(), + status: "stale".to_string(), + logs: vec!["runtime worker: 构建完成,预览地址已生成".to_string()], + artifact_id: None, + preview_token_id: None, + preview_url: None, + error_summary: Some("job snapshot 已不是项目 active snapshot".to_string()), + created_at_micros: 1_700_000_000_000_000, + started_at_micros: Some(1_700_000_005_000_000), + finished_at_micros: Some(1_700_000_010_000_000), + updated_at_micros: 1_700_000_010_000_000, + }), + job: Some(WebProjectRuntimeJobSnapshot { + job_id: "job-1".to_string(), + project_id: "project-1".to_string(), + snapshot_id: "snapshot-1".to_string(), + owner_user_id: "user-1".to_string(), + job_kind: "preview_build".to_string(), + status: "stale".to_string(), + attempt: 1, + worker_id: None, + lease_token: None, + lease_expires_at_micros: None, + cancel_requested_at_micros: None, + stale_reason: Some("job snapshot 已不是项目 active snapshot".to_string()), + artifact_id: None, + preview_build_id: Some("build-1".to_string()), + error_summary: None, + created_at_micros: 1_700_000_000_000_000, + started_at_micros: Some(1_700_000_005_000_000), + finished_at_micros: Some(1_700_000_010_000_000), + updated_at_micros: 1_700_000_010_000_000, + }), + error_message: None, + }; + + let mapped = map_web_project_runtime_job_preview_build_procedure_result(result) + .expect("combo result should map"); + + assert_eq!( + mapped.project.active_preview_build_id.as_deref(), + Some("build-2") + ); + assert_eq!(mapped.build.status, "stale"); + assert_eq!(mapped.build.preview_url, None); + assert_eq!(mapped.job.status, "stale"); + assert_eq!( + mapped.job.stale_reason.as_deref(), + Some("job snapshot 已不是项目 active snapshot") + ); + } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 925949a2..1e1dde5a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.4.1 (commit 07b52763c9da8d7cf79780db222fec1ffcb84070). +// This was generated using spacetimedb cli version 2.5.0 (commit 19cbf2b219ac9fd7fdca6c5e2c61a3a2f0319515). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -68,6 +68,7 @@ pub mod analytics_metric_query_input_type; pub mod analytics_metric_query_procedure_result_type; pub mod append_ai_text_chunk_and_return_procedure; pub mod append_visual_novel_runtime_history_entry_procedure; +pub mod append_web_project_runtime_job_log_and_return_procedure; pub mod apply_chapter_progression_ledger_entry_and_return_procedure; pub mod apply_chapter_progression_ledger_entry_reducer; pub mod apply_inventory_mutation_reducer; @@ -195,6 +196,7 @@ pub mod big_fish_works_procedure_result_type; pub mod bind_asset_object_to_entity_and_return_procedure; pub mod bind_asset_object_to_entity_reducer; pub mod cancel_ai_task_and_return_procedure; +pub mod cancel_web_project_runtime_job_and_return_procedure; pub mod chapter_pace_band_type; pub mod chapter_progression_get_input_type; pub mod chapter_progression_input_type; @@ -208,6 +210,7 @@ pub mod claim_external_generation_jobs_and_return_procedure; pub mod claim_profile_task_reward_and_return_procedure; pub mod claim_puzzle_background_compile_task_procedure; pub mod claim_puzzle_work_point_incentive_procedure; +pub mod claim_web_project_runtime_jobs_and_return_procedure; pub mod clear_database_migration_import_chunks_procedure; pub mod clear_platform_browse_history_and_return_procedure; pub mod click_match_3_d_item_procedure; @@ -224,6 +227,8 @@ pub mod compile_wooden_fish_draft_procedure; pub mod complete_ai_stage_and_return_procedure; pub mod complete_ai_task_and_return_procedure; pub mod complete_external_generation_job_and_return_procedure; +pub mod complete_web_project_preview_build_runtime_job_and_return_procedure; +pub mod complete_web_project_runtime_job_and_return_procedure; pub mod confirm_asset_object_and_return_procedure; pub mod confirm_asset_object_reducer; pub mod consume_inventory_item_input_type; @@ -250,6 +255,7 @@ pub mod create_square_hole_agent_session_procedure; pub mod create_visual_novel_agent_session_procedure; pub mod create_web_project_and_return_procedure; pub mod create_web_project_preview_build_and_return_procedure; +pub mod create_web_project_runtime_job_and_return_procedure; pub mod create_wooden_fish_agent_session_procedure; pub mod creation_entry_config_procedure_result_type; pub mod creation_entry_config_snapshot_type; @@ -398,6 +404,7 @@ pub mod enqueue_external_generation_job_and_return_procedure; pub mod ensure_analytics_date_dimension_for_date_reducer; pub mod equip_inventory_item_input_type; pub mod execute_custom_world_agent_action_procedure; +pub mod expire_web_project_runtime_job_and_return_procedure; pub mod export_auth_store_snapshot_from_tables_procedure; pub mod export_database_migration_to_file_procedure; pub mod external_generation_job_claim_input_type; @@ -414,6 +421,7 @@ pub mod external_generation_queue_stats_procedure_result_type; pub mod external_generation_queue_stats_snapshot_type; pub mod fail_ai_task_and_return_procedure; pub mod fail_external_generation_job_and_return_procedure; +pub mod fail_web_project_runtime_job_and_return_procedure; pub mod finalize_big_fish_agent_message_turn_procedure; pub mod finalize_custom_world_agent_message_turn_procedure; pub mod finalize_match_3_d_agent_message_turn_procedure; @@ -477,6 +485,7 @@ pub mod get_visual_novel_work_detail_procedure; pub mod get_web_project_and_return_procedure; pub mod get_web_project_preview_build_and_return_procedure; pub mod get_web_project_preview_build_by_token_and_return_procedure; +pub mod get_web_project_runtime_job_and_return_procedure; pub mod get_web_project_snapshot_and_return_procedure; pub mod get_wooden_fish_agent_session_procedure; pub mod get_wooden_fish_run_procedure; @@ -559,6 +568,7 @@ pub mod list_custom_world_works_procedure; pub mod list_editor_projects_and_return_procedure; pub mod list_jump_hop_works_procedure; pub mod list_match_3_d_works_procedure; +pub mod list_open_web_project_runtime_jobs_and_return_procedure; pub mod list_platform_browse_history_procedure; pub mod list_profile_save_archives_procedure; pub mod list_profile_wallet_ledger_procedure; @@ -568,11 +578,13 @@ pub mod list_puzzle_works_procedure; pub mod list_square_hole_works_procedure; pub mod list_visual_novel_runtime_history_procedure; pub mod list_visual_novel_works_procedure; +pub mod list_web_project_runtime_job_logs_and_return_procedure; pub mod list_wooden_fish_works_procedure; pub mod mark_profile_recharge_order_paid_and_return_procedure; pub mod mark_puzzle_clear_level_time_up_procedure; pub mod mark_puzzle_draft_generation_failed_procedure; pub mod mark_puzzle_level_generation_failed_procedure; +pub mod mark_web_project_runtime_job_stale_and_return_procedure; pub mod match_3_d_agent_message_finalize_input_type; pub mod match_3_d_agent_message_row_type; pub mod match_3_d_agent_message_snapshot_type; @@ -855,6 +867,7 @@ pub mod remix_custom_world_profile_procedure; pub mod remix_puzzle_work_procedure; pub mod rename_editor_project_and_return_procedure; pub mod renew_external_generation_job_lease_and_return_procedure; +pub mod renew_web_project_runtime_job_lease_and_return_procedure; pub mod resolve_combat_action_and_return_procedure; pub mod resolve_combat_action_input_type; pub mod resolve_combat_action_procedure_result_type; @@ -1181,6 +1194,27 @@ pub mod web_project_preview_build_token_get_input_type; pub mod web_project_preview_build_update_input_type; pub mod web_project_procedure_result_type; pub mod web_project_project_snapshot_type; +pub mod web_project_runtime_job_append_log_input_type; +pub mod web_project_runtime_job_cancel_input_type; +pub mod web_project_runtime_job_claim_input_type; +pub mod web_project_runtime_job_complete_input_type; +pub mod web_project_runtime_job_complete_preview_build_input_type; +pub mod web_project_runtime_job_create_input_type; +pub mod web_project_runtime_job_expire_input_type; +pub mod web_project_runtime_job_fail_input_type; +pub mod web_project_runtime_job_get_input_type; +pub mod web_project_runtime_job_list_logs_input_type; +pub mod web_project_runtime_job_list_open_input_type; +pub mod web_project_runtime_job_log_row_type; +pub mod web_project_runtime_job_log_snapshot_type; +pub mod web_project_runtime_job_log_table; +pub mod web_project_runtime_job_preview_build_procedure_result_type; +pub mod web_project_runtime_job_procedure_result_type; +pub mod web_project_runtime_job_renew_lease_input_type; +pub mod web_project_runtime_job_row_type; +pub mod web_project_runtime_job_snapshot_type; +pub mod web_project_runtime_job_stale_input_type; +pub mod web_project_runtime_job_table; pub mod web_project_service_identity_authorize_input_type; pub mod web_project_service_identity_procedure_result_type; pub mod web_project_service_identity_revoke_input_type; @@ -1294,6 +1328,7 @@ pub use analytics_metric_query_input_type::AnalyticsMetricQueryInput; pub use analytics_metric_query_procedure_result_type::AnalyticsMetricQueryProcedureResult; pub use append_ai_text_chunk_and_return_procedure::append_ai_text_chunk_and_return; pub use append_visual_novel_runtime_history_entry_procedure::append_visual_novel_runtime_history_entry; +pub use append_web_project_runtime_job_log_and_return_procedure::append_web_project_runtime_job_log_and_return; pub use apply_chapter_progression_ledger_entry_and_return_procedure::apply_chapter_progression_ledger_entry_and_return; pub use apply_chapter_progression_ledger_entry_reducer::apply_chapter_progression_ledger_entry; pub use apply_inventory_mutation_reducer::apply_inventory_mutation; @@ -1421,6 +1456,7 @@ pub use big_fish_works_procedure_result_type::BigFishWorksProcedureResult; pub use bind_asset_object_to_entity_and_return_procedure::bind_asset_object_to_entity_and_return; pub use bind_asset_object_to_entity_reducer::bind_asset_object_to_entity; pub use cancel_ai_task_and_return_procedure::cancel_ai_task_and_return; +pub use cancel_web_project_runtime_job_and_return_procedure::cancel_web_project_runtime_job_and_return; pub use chapter_pace_band_type::ChapterPaceBand; pub use chapter_progression_get_input_type::ChapterProgressionGetInput; pub use chapter_progression_input_type::ChapterProgressionInput; @@ -1434,6 +1470,7 @@ pub use claim_external_generation_jobs_and_return_procedure::claim_external_gene pub use claim_profile_task_reward_and_return_procedure::claim_profile_task_reward_and_return; pub use claim_puzzle_background_compile_task_procedure::claim_puzzle_background_compile_task; pub use claim_puzzle_work_point_incentive_procedure::claim_puzzle_work_point_incentive; +pub use claim_web_project_runtime_jobs_and_return_procedure::claim_web_project_runtime_jobs_and_return; pub use clear_database_migration_import_chunks_procedure::clear_database_migration_import_chunks; pub use clear_platform_browse_history_and_return_procedure::clear_platform_browse_history_and_return; pub use click_match_3_d_item_procedure::click_match_3_d_item; @@ -1450,6 +1487,8 @@ pub use compile_wooden_fish_draft_procedure::compile_wooden_fish_draft; pub use complete_ai_stage_and_return_procedure::complete_ai_stage_and_return; pub use complete_ai_task_and_return_procedure::complete_ai_task_and_return; pub use complete_external_generation_job_and_return_procedure::complete_external_generation_job_and_return; +pub use complete_web_project_preview_build_runtime_job_and_return_procedure::complete_web_project_preview_build_runtime_job_and_return; +pub use complete_web_project_runtime_job_and_return_procedure::complete_web_project_runtime_job_and_return; pub use confirm_asset_object_and_return_procedure::confirm_asset_object_and_return; pub use confirm_asset_object_reducer::confirm_asset_object; pub use consume_inventory_item_input_type::ConsumeInventoryItemInput; @@ -1476,6 +1515,7 @@ pub use create_square_hole_agent_session_procedure::create_square_hole_agent_ses pub use create_visual_novel_agent_session_procedure::create_visual_novel_agent_session; pub use create_web_project_and_return_procedure::create_web_project_and_return; pub use create_web_project_preview_build_and_return_procedure::create_web_project_preview_build_and_return; +pub use create_web_project_runtime_job_and_return_procedure::create_web_project_runtime_job_and_return; pub use create_wooden_fish_agent_session_procedure::create_wooden_fish_agent_session; pub use creation_entry_config_procedure_result_type::CreationEntryConfigProcedureResult; pub use creation_entry_config_snapshot_type::CreationEntryConfigSnapshot; @@ -1624,6 +1664,7 @@ pub use enqueue_external_generation_job_and_return_procedure::enqueue_external_g pub use ensure_analytics_date_dimension_for_date_reducer::ensure_analytics_date_dimension_for_date; pub use equip_inventory_item_input_type::EquipInventoryItemInput; pub use execute_custom_world_agent_action_procedure::execute_custom_world_agent_action; +pub use expire_web_project_runtime_job_and_return_procedure::expire_web_project_runtime_job_and_return; pub use export_auth_store_snapshot_from_tables_procedure::export_auth_store_snapshot_from_tables; pub use export_database_migration_to_file_procedure::export_database_migration_to_file; pub use external_generation_job_claim_input_type::ExternalGenerationJobClaimInput; @@ -1640,6 +1681,7 @@ pub use external_generation_queue_stats_procedure_result_type::ExternalGeneratio pub use external_generation_queue_stats_snapshot_type::ExternalGenerationQueueStatsSnapshot; pub use fail_ai_task_and_return_procedure::fail_ai_task_and_return; pub use fail_external_generation_job_and_return_procedure::fail_external_generation_job_and_return; +pub use fail_web_project_runtime_job_and_return_procedure::fail_web_project_runtime_job_and_return; pub use finalize_big_fish_agent_message_turn_procedure::finalize_big_fish_agent_message_turn; pub use finalize_custom_world_agent_message_turn_procedure::finalize_custom_world_agent_message_turn; pub use finalize_match_3_d_agent_message_turn_procedure::finalize_match_3_d_agent_message_turn; @@ -1703,6 +1745,7 @@ pub use get_visual_novel_work_detail_procedure::get_visual_novel_work_detail; pub use get_web_project_and_return_procedure::get_web_project_and_return; pub use get_web_project_preview_build_and_return_procedure::get_web_project_preview_build_and_return; pub use get_web_project_preview_build_by_token_and_return_procedure::get_web_project_preview_build_by_token_and_return; +pub use get_web_project_runtime_job_and_return_procedure::get_web_project_runtime_job_and_return; pub use get_web_project_snapshot_and_return_procedure::get_web_project_snapshot_and_return; pub use get_wooden_fish_agent_session_procedure::get_wooden_fish_agent_session; pub use get_wooden_fish_run_procedure::get_wooden_fish_run; @@ -1785,6 +1828,7 @@ pub use list_custom_world_works_procedure::list_custom_world_works; pub use list_editor_projects_and_return_procedure::list_editor_projects_and_return; pub use list_jump_hop_works_procedure::list_jump_hop_works; pub use list_match_3_d_works_procedure::list_match_3_d_works; +pub use list_open_web_project_runtime_jobs_and_return_procedure::list_open_web_project_runtime_jobs_and_return; pub use list_platform_browse_history_procedure::list_platform_browse_history; pub use list_profile_save_archives_procedure::list_profile_save_archives; pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger; @@ -1794,11 +1838,13 @@ pub use list_puzzle_works_procedure::list_puzzle_works; pub use list_square_hole_works_procedure::list_square_hole_works; pub use list_visual_novel_runtime_history_procedure::list_visual_novel_runtime_history; pub use list_visual_novel_works_procedure::list_visual_novel_works; +pub use list_web_project_runtime_job_logs_and_return_procedure::list_web_project_runtime_job_logs_and_return; pub use list_wooden_fish_works_procedure::list_wooden_fish_works; pub use mark_profile_recharge_order_paid_and_return_procedure::mark_profile_recharge_order_paid_and_return; pub use mark_puzzle_clear_level_time_up_procedure::mark_puzzle_clear_level_time_up; pub use mark_puzzle_draft_generation_failed_procedure::mark_puzzle_draft_generation_failed; pub use mark_puzzle_level_generation_failed_procedure::mark_puzzle_level_generation_failed; +pub use mark_web_project_runtime_job_stale_and_return_procedure::mark_web_project_runtime_job_stale_and_return; pub use match_3_d_agent_message_finalize_input_type::Match3DAgentMessageFinalizeInput; pub use match_3_d_agent_message_row_type::Match3DAgentMessageRow; pub use match_3_d_agent_message_snapshot_type::Match3DAgentMessageSnapshot; @@ -2081,6 +2127,7 @@ pub use remix_custom_world_profile_procedure::remix_custom_world_profile; pub use remix_puzzle_work_procedure::remix_puzzle_work; pub use rename_editor_project_and_return_procedure::rename_editor_project_and_return; pub use renew_external_generation_job_lease_and_return_procedure::renew_external_generation_job_lease_and_return; +pub use renew_web_project_runtime_job_lease_and_return_procedure::renew_web_project_runtime_job_lease_and_return; pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return; pub use resolve_combat_action_input_type::ResolveCombatActionInput; pub use resolve_combat_action_procedure_result_type::ResolveCombatActionProcedureResult; @@ -2407,6 +2454,27 @@ pub use web_project_preview_build_token_get_input_type::WebProjectPreviewBuildTo pub use web_project_preview_build_update_input_type::WebProjectPreviewBuildUpdateInput; pub use web_project_procedure_result_type::WebProjectProcedureResult; pub use web_project_project_snapshot_type::WebProjectProjectSnapshot; +pub use web_project_runtime_job_append_log_input_type::WebProjectRuntimeJobAppendLogInput; +pub use web_project_runtime_job_cancel_input_type::WebProjectRuntimeJobCancelInput; +pub use web_project_runtime_job_claim_input_type::WebProjectRuntimeJobClaimInput; +pub use web_project_runtime_job_complete_input_type::WebProjectRuntimeJobCompleteInput; +pub use web_project_runtime_job_complete_preview_build_input_type::WebProjectRuntimeJobCompletePreviewBuildInput; +pub use web_project_runtime_job_create_input_type::WebProjectRuntimeJobCreateInput; +pub use web_project_runtime_job_expire_input_type::WebProjectRuntimeJobExpireInput; +pub use web_project_runtime_job_fail_input_type::WebProjectRuntimeJobFailInput; +pub use web_project_runtime_job_get_input_type::WebProjectRuntimeJobGetInput; +pub use web_project_runtime_job_list_logs_input_type::WebProjectRuntimeJobListLogsInput; +pub use web_project_runtime_job_list_open_input_type::WebProjectRuntimeJobListOpenInput; +pub use web_project_runtime_job_log_row_type::WebProjectRuntimeJobLogRow; +pub use web_project_runtime_job_log_snapshot_type::WebProjectRuntimeJobLogSnapshot; +pub use web_project_runtime_job_log_table::*; +pub use web_project_runtime_job_preview_build_procedure_result_type::WebProjectRuntimeJobPreviewBuildProcedureResult; +pub use web_project_runtime_job_procedure_result_type::WebProjectRuntimeJobProcedureResult; +pub use web_project_runtime_job_renew_lease_input_type::WebProjectRuntimeJobRenewLeaseInput; +pub use web_project_runtime_job_row_type::WebProjectRuntimeJobRow; +pub use web_project_runtime_job_snapshot_type::WebProjectRuntimeJobSnapshot; +pub use web_project_runtime_job_stale_input_type::WebProjectRuntimeJobStaleInput; +pub use web_project_runtime_job_table::*; pub use web_project_service_identity_authorize_input_type::WebProjectServiceIdentityAuthorizeInput; pub use web_project_service_identity_procedure_result_type::WebProjectServiceIdentityProcedureResult; pub use web_project_service_identity_revoke_input_type::WebProjectServiceIdentityRevokeInput; @@ -2850,6 +2918,8 @@ pub struct DbUpdate { visual_novel_work_profile: __sdk::TableUpdate, web_project: __sdk::TableUpdate, web_project_preview_build: __sdk::TableUpdate, + web_project_runtime_job: __sdk::TableUpdate, + web_project_runtime_job_log: __sdk::TableUpdate, web_project_service_identity: __sdk::TableUpdate, web_project_snapshot: __sdk::TableUpdate, wooden_fish_agent_session: __sdk::TableUpdate, @@ -3242,6 +3312,12 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "web_project_preview_build" => db_update.web_project_preview_build.append( web_project_preview_build_table::parse_table_update(table_update)?, ), + "web_project_runtime_job" => db_update.web_project_runtime_job.append( + web_project_runtime_job_table::parse_table_update(table_update)?, + ), + "web_project_runtime_job_log" => db_update.web_project_runtime_job_log.append( + web_project_runtime_job_log_table::parse_table_update(table_update)?, + ), "web_project_service_identity" => db_update.web_project_service_identity.append( web_project_service_identity_table::parse_table_update(table_update)?, ), @@ -3837,6 +3913,18 @@ impl __sdk::DbUpdate for DbUpdate { &self.web_project_preview_build, ) .with_updates_by_pk(|row| &row.job_id); + diff.web_project_runtime_job = cache + .apply_diff_to_table::( + "web_project_runtime_job", + &self.web_project_runtime_job, + ) + .with_updates_by_pk(|row| &row.job_id); + diff.web_project_runtime_job_log = cache + .apply_diff_to_table::( + "web_project_runtime_job_log", + &self.web_project_runtime_job_log, + ) + .with_updates_by_pk(|row| &row.log_id); diff.web_project_service_identity = cache .apply_diff_to_table::( "web_project_service_identity", @@ -4299,6 +4387,12 @@ impl __sdk::DbUpdate for DbUpdate { "web_project_preview_build" => db_update .web_project_preview_build .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "web_project_runtime_job" => db_update + .web_project_runtime_job + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "web_project_runtime_job_log" => db_update + .web_project_runtime_job_log + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "web_project_service_identity" => db_update .web_project_service_identity .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -4696,6 +4790,12 @@ impl __sdk::DbUpdate for DbUpdate { "web_project_preview_build" => db_update .web_project_preview_build .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "web_project_runtime_job" => db_update + .web_project_runtime_job + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "web_project_runtime_job_log" => db_update + .web_project_runtime_job_log + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "web_project_service_identity" => db_update .web_project_service_identity .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -4858,6 +4958,8 @@ pub struct AppliedDiff<'r> { visual_novel_work_profile: __sdk::TableAppliedDiff<'r, VisualNovelWorkProfileRow>, web_project: __sdk::TableAppliedDiff<'r, WebProject>, web_project_preview_build: __sdk::TableAppliedDiff<'r, WebProjectPreviewBuildRow>, + web_project_runtime_job: __sdk::TableAppliedDiff<'r, WebProjectRuntimeJobRow>, + web_project_runtime_job_log: __sdk::TableAppliedDiff<'r, WebProjectRuntimeJobLogRow>, web_project_service_identity: __sdk::TableAppliedDiff<'r, WebProjectServiceIdentity>, web_project_snapshot: __sdk::TableAppliedDiff<'r, WebProjectSnapshotRow>, wooden_fish_agent_session: __sdk::TableAppliedDiff<'r, WoodenFishAgentSessionRow>, @@ -5455,6 +5557,16 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.web_project_preview_build, event, ); + callbacks.invoke_table_row_callbacks::( + "web_project_runtime_job", + &self.web_project_runtime_job, + event, + ); + callbacks.invoke_table_row_callbacks::( + "web_project_runtime_job_log", + &self.web_project_runtime_job_log, + event, + ); callbacks.invoke_table_row_callbacks::( "web_project_service_identity", &self.web_project_service_identity, @@ -6275,6 +6387,8 @@ impl __sdk::SpacetimeModule for RemoteModule { visual_novel_work_profile_table::register_table(client_cache); web_project_table::register_table(client_cache); web_project_preview_build_table::register_table(client_cache); + web_project_runtime_job_table::register_table(client_cache); + web_project_runtime_job_log_table::register_table(client_cache); web_project_service_identity_table::register_table(client_cache); web_project_snapshot_table::register_table(client_cache); wooden_fish_agent_session_table::register_table(client_cache); @@ -6405,6 +6519,8 @@ impl __sdk::SpacetimeModule for RemoteModule { "visual_novel_work_profile", "web_project", "web_project_preview_build", + "web_project_runtime_job", + "web_project_runtime_job_log", "web_project_service_identity", "web_project_snapshot", "wooden_fish_agent_session", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/append_web_project_runtime_job_log_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/append_web_project_runtime_job_log_and_return_procedure.rs new file mode 100644 index 00000000..556b41e8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/append_web_project_runtime_job_log_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::web_project_runtime_job_append_log_input_type::WebProjectRuntimeJobAppendLogInput; +use super::web_project_runtime_job_procedure_result_type::WebProjectRuntimeJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AppendWebProjectRuntimeJobLogAndReturnArgs { + pub input: WebProjectRuntimeJobAppendLogInput, +} + +impl __sdk::InModule for AppendWebProjectRuntimeJobLogAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `append_web_project_runtime_job_log_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait append_web_project_runtime_job_log_and_return { + fn append_web_project_runtime_job_log_and_return( + &self, + input: WebProjectRuntimeJobAppendLogInput, + ) { + self.append_web_project_runtime_job_log_and_return_then(input, |_, _| {}); + } + + fn append_web_project_runtime_job_log_and_return_then( + &self, + input: WebProjectRuntimeJobAppendLogInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl append_web_project_runtime_job_log_and_return for super::RemoteProcedures { + fn append_web_project_runtime_job_log_and_return_then( + &self, + input: WebProjectRuntimeJobAppendLogInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WebProjectRuntimeJobProcedureResult>( + "append_web_project_runtime_job_log_and_return", + AppendWebProjectRuntimeJobLogAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/cancel_web_project_runtime_job_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/cancel_web_project_runtime_job_and_return_procedure.rs new file mode 100644 index 00000000..382d0a40 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/cancel_web_project_runtime_job_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::web_project_runtime_job_cancel_input_type::WebProjectRuntimeJobCancelInput; +use super::web_project_runtime_job_procedure_result_type::WebProjectRuntimeJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CancelWebProjectRuntimeJobAndReturnArgs { + pub input: WebProjectRuntimeJobCancelInput, +} + +impl __sdk::InModule for CancelWebProjectRuntimeJobAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `cancel_web_project_runtime_job_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait cancel_web_project_runtime_job_and_return { + fn cancel_web_project_runtime_job_and_return(&self, input: WebProjectRuntimeJobCancelInput) { + self.cancel_web_project_runtime_job_and_return_then(input, |_, _| {}); + } + + fn cancel_web_project_runtime_job_and_return_then( + &self, + input: WebProjectRuntimeJobCancelInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl cancel_web_project_runtime_job_and_return for super::RemoteProcedures { + fn cancel_web_project_runtime_job_and_return_then( + &self, + input: WebProjectRuntimeJobCancelInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WebProjectRuntimeJobProcedureResult>( + "cancel_web_project_runtime_job_and_return", + CancelWebProjectRuntimeJobAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/claim_web_project_runtime_jobs_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/claim_web_project_runtime_jobs_and_return_procedure.rs new file mode 100644 index 00000000..d2eb3ad0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/claim_web_project_runtime_jobs_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::web_project_runtime_job_claim_input_type::WebProjectRuntimeJobClaimInput; +use super::web_project_runtime_job_procedure_result_type::WebProjectRuntimeJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ClaimWebProjectRuntimeJobsAndReturnArgs { + pub input: WebProjectRuntimeJobClaimInput, +} + +impl __sdk::InModule for ClaimWebProjectRuntimeJobsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `claim_web_project_runtime_jobs_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait claim_web_project_runtime_jobs_and_return { + fn claim_web_project_runtime_jobs_and_return(&self, input: WebProjectRuntimeJobClaimInput) { + self.claim_web_project_runtime_jobs_and_return_then(input, |_, _| {}); + } + + fn claim_web_project_runtime_jobs_and_return_then( + &self, + input: WebProjectRuntimeJobClaimInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl claim_web_project_runtime_jobs_and_return for super::RemoteProcedures { + fn claim_web_project_runtime_jobs_and_return_then( + &self, + input: WebProjectRuntimeJobClaimInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WebProjectRuntimeJobProcedureResult>( + "claim_web_project_runtime_jobs_and_return", + ClaimWebProjectRuntimeJobsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/complete_web_project_preview_build_runtime_job_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/complete_web_project_preview_build_runtime_job_and_return_procedure.rs new file mode 100644 index 00000000..937da68e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/complete_web_project_preview_build_runtime_job_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::web_project_runtime_job_complete_preview_build_input_type::WebProjectRuntimeJobCompletePreviewBuildInput; +use super::web_project_runtime_job_preview_build_procedure_result_type::WebProjectRuntimeJobPreviewBuildProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CompleteWebProjectPreviewBuildRuntimeJobAndReturnArgs { + pub input: WebProjectRuntimeJobCompletePreviewBuildInput, +} + +impl __sdk::InModule for CompleteWebProjectPreviewBuildRuntimeJobAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `complete_web_project_preview_build_runtime_job_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait complete_web_project_preview_build_runtime_job_and_return { + fn complete_web_project_preview_build_runtime_job_and_return( + &self, + input: WebProjectRuntimeJobCompletePreviewBuildInput, + ) { + self.complete_web_project_preview_build_runtime_job_and_return_then(input, |_, _| {}); + } + + fn complete_web_project_preview_build_runtime_job_and_return_then( + &self, + input: WebProjectRuntimeJobCompletePreviewBuildInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl complete_web_project_preview_build_runtime_job_and_return for super::RemoteProcedures { + fn complete_web_project_preview_build_runtime_job_and_return_then( + &self, + input: WebProjectRuntimeJobCompletePreviewBuildInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WebProjectRuntimeJobPreviewBuildProcedureResult>( + "complete_web_project_preview_build_runtime_job_and_return", + CompleteWebProjectPreviewBuildRuntimeJobAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/complete_web_project_runtime_job_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/complete_web_project_runtime_job_and_return_procedure.rs new file mode 100644 index 00000000..c38f2f4d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/complete_web_project_runtime_job_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::web_project_runtime_job_complete_input_type::WebProjectRuntimeJobCompleteInput; +use super::web_project_runtime_job_procedure_result_type::WebProjectRuntimeJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CompleteWebProjectRuntimeJobAndReturnArgs { + pub input: WebProjectRuntimeJobCompleteInput, +} + +impl __sdk::InModule for CompleteWebProjectRuntimeJobAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `complete_web_project_runtime_job_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait complete_web_project_runtime_job_and_return { + fn complete_web_project_runtime_job_and_return( + &self, + input: WebProjectRuntimeJobCompleteInput, + ) { + self.complete_web_project_runtime_job_and_return_then(input, |_, _| {}); + } + + fn complete_web_project_runtime_job_and_return_then( + &self, + input: WebProjectRuntimeJobCompleteInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl complete_web_project_runtime_job_and_return for super::RemoteProcedures { + fn complete_web_project_runtime_job_and_return_then( + &self, + input: WebProjectRuntimeJobCompleteInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WebProjectRuntimeJobProcedureResult>( + "complete_web_project_runtime_job_and_return", + CompleteWebProjectRuntimeJobAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_web_project_runtime_job_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_web_project_runtime_job_and_return_procedure.rs new file mode 100644 index 00000000..307d1d09 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_web_project_runtime_job_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::web_project_runtime_job_create_input_type::WebProjectRuntimeJobCreateInput; +use super::web_project_runtime_job_procedure_result_type::WebProjectRuntimeJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CreateWebProjectRuntimeJobAndReturnArgs { + pub input: WebProjectRuntimeJobCreateInput, +} + +impl __sdk::InModule for CreateWebProjectRuntimeJobAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `create_web_project_runtime_job_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait create_web_project_runtime_job_and_return { + fn create_web_project_runtime_job_and_return(&self, input: WebProjectRuntimeJobCreateInput) { + self.create_web_project_runtime_job_and_return_then(input, |_, _| {}); + } + + fn create_web_project_runtime_job_and_return_then( + &self, + input: WebProjectRuntimeJobCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl create_web_project_runtime_job_and_return for super::RemoteProcedures { + fn create_web_project_runtime_job_and_return_then( + &self, + input: WebProjectRuntimeJobCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WebProjectRuntimeJobProcedureResult>( + "create_web_project_runtime_job_and_return", + CreateWebProjectRuntimeJobAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/expire_web_project_runtime_job_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/expire_web_project_runtime_job_and_return_procedure.rs new file mode 100644 index 00000000..ea27e8a4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/expire_web_project_runtime_job_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::web_project_runtime_job_expire_input_type::WebProjectRuntimeJobExpireInput; +use super::web_project_runtime_job_procedure_result_type::WebProjectRuntimeJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ExpireWebProjectRuntimeJobAndReturnArgs { + pub input: WebProjectRuntimeJobExpireInput, +} + +impl __sdk::InModule for ExpireWebProjectRuntimeJobAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `expire_web_project_runtime_job_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait expire_web_project_runtime_job_and_return { + fn expire_web_project_runtime_job_and_return(&self, input: WebProjectRuntimeJobExpireInput) { + self.expire_web_project_runtime_job_and_return_then(input, |_, _| {}); + } + + fn expire_web_project_runtime_job_and_return_then( + &self, + input: WebProjectRuntimeJobExpireInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl expire_web_project_runtime_job_and_return for super::RemoteProcedures { + fn expire_web_project_runtime_job_and_return_then( + &self, + input: WebProjectRuntimeJobExpireInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WebProjectRuntimeJobProcedureResult>( + "expire_web_project_runtime_job_and_return", + ExpireWebProjectRuntimeJobAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/fail_web_project_runtime_job_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/fail_web_project_runtime_job_and_return_procedure.rs new file mode 100644 index 00000000..30c9e355 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/fail_web_project_runtime_job_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::web_project_runtime_job_fail_input_type::WebProjectRuntimeJobFailInput; +use super::web_project_runtime_job_procedure_result_type::WebProjectRuntimeJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct FailWebProjectRuntimeJobAndReturnArgs { + pub input: WebProjectRuntimeJobFailInput, +} + +impl __sdk::InModule for FailWebProjectRuntimeJobAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `fail_web_project_runtime_job_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait fail_web_project_runtime_job_and_return { + fn fail_web_project_runtime_job_and_return(&self, input: WebProjectRuntimeJobFailInput) { + self.fail_web_project_runtime_job_and_return_then(input, |_, _| {}); + } + + fn fail_web_project_runtime_job_and_return_then( + &self, + input: WebProjectRuntimeJobFailInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl fail_web_project_runtime_job_and_return for super::RemoteProcedures { + fn fail_web_project_runtime_job_and_return_then( + &self, + input: WebProjectRuntimeJobFailInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WebProjectRuntimeJobProcedureResult>( + "fail_web_project_runtime_job_and_return", + FailWebProjectRuntimeJobAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_web_project_runtime_job_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_web_project_runtime_job_and_return_procedure.rs new file mode 100644 index 00000000..c19b2911 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_web_project_runtime_job_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::web_project_runtime_job_get_input_type::WebProjectRuntimeJobGetInput; +use super::web_project_runtime_job_procedure_result_type::WebProjectRuntimeJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetWebProjectRuntimeJobAndReturnArgs { + pub input: WebProjectRuntimeJobGetInput, +} + +impl __sdk::InModule for GetWebProjectRuntimeJobAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_web_project_runtime_job_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_web_project_runtime_job_and_return { + fn get_web_project_runtime_job_and_return(&self, input: WebProjectRuntimeJobGetInput) { + self.get_web_project_runtime_job_and_return_then(input, |_, _| {}); + } + + fn get_web_project_runtime_job_and_return_then( + &self, + input: WebProjectRuntimeJobGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_web_project_runtime_job_and_return for super::RemoteProcedures { + fn get_web_project_runtime_job_and_return_then( + &self, + input: WebProjectRuntimeJobGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WebProjectRuntimeJobProcedureResult>( + "get_web_project_runtime_job_and_return", + GetWebProjectRuntimeJobAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_open_web_project_runtime_jobs_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_open_web_project_runtime_jobs_and_return_procedure.rs new file mode 100644 index 00000000..9099dedd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_open_web_project_runtime_jobs_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::web_project_runtime_job_list_open_input_type::WebProjectRuntimeJobListOpenInput; +use super::web_project_runtime_job_procedure_result_type::WebProjectRuntimeJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListOpenWebProjectRuntimeJobsAndReturnArgs { + pub input: WebProjectRuntimeJobListOpenInput, +} + +impl __sdk::InModule for ListOpenWebProjectRuntimeJobsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_open_web_project_runtime_jobs_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_open_web_project_runtime_jobs_and_return { + fn list_open_web_project_runtime_jobs_and_return( + &self, + input: WebProjectRuntimeJobListOpenInput, + ) { + self.list_open_web_project_runtime_jobs_and_return_then(input, |_, _| {}); + } + + fn list_open_web_project_runtime_jobs_and_return_then( + &self, + input: WebProjectRuntimeJobListOpenInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_open_web_project_runtime_jobs_and_return for super::RemoteProcedures { + fn list_open_web_project_runtime_jobs_and_return_then( + &self, + input: WebProjectRuntimeJobListOpenInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WebProjectRuntimeJobProcedureResult>( + "list_open_web_project_runtime_jobs_and_return", + ListOpenWebProjectRuntimeJobsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_web_project_runtime_job_logs_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_web_project_runtime_job_logs_and_return_procedure.rs new file mode 100644 index 00000000..08094324 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_web_project_runtime_job_logs_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::web_project_runtime_job_list_logs_input_type::WebProjectRuntimeJobListLogsInput; +use super::web_project_runtime_job_procedure_result_type::WebProjectRuntimeJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListWebProjectRuntimeJobLogsAndReturnArgs { + pub input: WebProjectRuntimeJobListLogsInput, +} + +impl __sdk::InModule for ListWebProjectRuntimeJobLogsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_web_project_runtime_job_logs_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_web_project_runtime_job_logs_and_return { + fn list_web_project_runtime_job_logs_and_return( + &self, + input: WebProjectRuntimeJobListLogsInput, + ) { + self.list_web_project_runtime_job_logs_and_return_then(input, |_, _| {}); + } + + fn list_web_project_runtime_job_logs_and_return_then( + &self, + input: WebProjectRuntimeJobListLogsInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_web_project_runtime_job_logs_and_return for super::RemoteProcedures { + fn list_web_project_runtime_job_logs_and_return_then( + &self, + input: WebProjectRuntimeJobListLogsInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WebProjectRuntimeJobProcedureResult>( + "list_web_project_runtime_job_logs_and_return", + ListWebProjectRuntimeJobLogsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mark_web_project_runtime_job_stale_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/mark_web_project_runtime_job_stale_and_return_procedure.rs new file mode 100644 index 00000000..70eccc8f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/mark_web_project_runtime_job_stale_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::web_project_runtime_job_procedure_result_type::WebProjectRuntimeJobProcedureResult; +use super::web_project_runtime_job_stale_input_type::WebProjectRuntimeJobStaleInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct MarkWebProjectRuntimeJobStaleAndReturnArgs { + pub input: WebProjectRuntimeJobStaleInput, +} + +impl __sdk::InModule for MarkWebProjectRuntimeJobStaleAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `mark_web_project_runtime_job_stale_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait mark_web_project_runtime_job_stale_and_return { + fn mark_web_project_runtime_job_stale_and_return(&self, input: WebProjectRuntimeJobStaleInput) { + self.mark_web_project_runtime_job_stale_and_return_then(input, |_, _| {}); + } + + fn mark_web_project_runtime_job_stale_and_return_then( + &self, + input: WebProjectRuntimeJobStaleInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl mark_web_project_runtime_job_stale_and_return for super::RemoteProcedures { + fn mark_web_project_runtime_job_stale_and_return_then( + &self, + input: WebProjectRuntimeJobStaleInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WebProjectRuntimeJobProcedureResult>( + "mark_web_project_runtime_job_stale_and_return", + MarkWebProjectRuntimeJobStaleAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/renew_web_project_runtime_job_lease_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/renew_web_project_runtime_job_lease_and_return_procedure.rs new file mode 100644 index 00000000..b803db26 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/renew_web_project_runtime_job_lease_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::web_project_runtime_job_procedure_result_type::WebProjectRuntimeJobProcedureResult; +use super::web_project_runtime_job_renew_lease_input_type::WebProjectRuntimeJobRenewLeaseInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RenewWebProjectRuntimeJobLeaseAndReturnArgs { + pub input: WebProjectRuntimeJobRenewLeaseInput, +} + +impl __sdk::InModule for RenewWebProjectRuntimeJobLeaseAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `renew_web_project_runtime_job_lease_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait renew_web_project_runtime_job_lease_and_return { + fn renew_web_project_runtime_job_lease_and_return( + &self, + input: WebProjectRuntimeJobRenewLeaseInput, + ) { + self.renew_web_project_runtime_job_lease_and_return_then(input, |_, _| {}); + } + + fn renew_web_project_runtime_job_lease_and_return_then( + &self, + input: WebProjectRuntimeJobRenewLeaseInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl renew_web_project_runtime_job_lease_and_return for super::RemoteProcedures { + fn renew_web_project_runtime_job_lease_and_return_then( + &self, + input: WebProjectRuntimeJobRenewLeaseInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WebProjectRuntimeJobProcedureResult>( + "renew_web_project_runtime_job_lease_and_return", + RenewWebProjectRuntimeJobLeaseAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_append_log_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_append_log_input_type.rs new file mode 100644 index 00000000..b136dec8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_append_log_input_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobAppendLogInput { + pub log_id: String, + pub job_id: String, + pub owner_user_id: String, + pub sequence: u64, + pub level: String, + pub message: String, + pub worker_id: Option, + pub lease_token: Option, + pub created_at_micros: i64, +} + +impl __sdk::InModule for WebProjectRuntimeJobAppendLogInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_cancel_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_cancel_input_type.rs new file mode 100644 index 00000000..a72d58a4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_cancel_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobCancelInput { + pub job_id: String, + pub owner_user_id: String, + pub cancelled_at_micros: i64, +} + +impl __sdk::InModule for WebProjectRuntimeJobCancelInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_claim_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_claim_input_type.rs new file mode 100644 index 00000000..1b236207 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_claim_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobClaimInput { + pub worker_id: String, + pub limit: u32, + pub lease_expires_at_micros: i64, + pub claimed_at_micros: i64, +} + +impl __sdk::InModule for WebProjectRuntimeJobClaimInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_complete_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_complete_input_type.rs new file mode 100644 index 00000000..595f1cb3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_complete_input_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobCompleteInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub artifact_id: Option, + pub preview_build_id: Option, + pub completed_at_micros: i64, +} + +impl __sdk::InModule for WebProjectRuntimeJobCompleteInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_complete_preview_build_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_complete_preview_build_input_type.rs new file mode 100644 index 00000000..caae5da1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_complete_preview_build_input_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobCompletePreviewBuildInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub preview_build_id: String, + pub artifact_id: String, + pub preview_token_id: String, + pub preview_url: String, + pub logs: Vec, + pub started_at_micros: Option, + pub finished_at_micros: i64, +} + +impl __sdk::InModule for WebProjectRuntimeJobCompletePreviewBuildInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_create_input_type.rs new file mode 100644 index 00000000..039c4e30 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_create_input_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobCreateInput { + pub job_id: String, + pub project_id: String, + pub snapshot_id: String, + pub owner_user_id: String, + pub job_kind: String, + pub preview_build_id: Option, + pub now_micros: i64, +} + +impl __sdk::InModule for WebProjectRuntimeJobCreateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_expire_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_expire_input_type.rs new file mode 100644 index 00000000..da774b80 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_expire_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobExpireInput { + pub job_id: String, + pub owner_user_id: String, + pub expired_at_micros: i64, +} + +impl __sdk::InModule for WebProjectRuntimeJobExpireInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_fail_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_fail_input_type.rs new file mode 100644 index 00000000..8ad08564 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_fail_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobFailInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub error_summary: String, + pub failed_at_micros: i64, +} + +impl __sdk::InModule for WebProjectRuntimeJobFailInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_get_input_type.rs new file mode 100644 index 00000000..d64472a7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_get_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobGetInput { + pub job_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for WebProjectRuntimeJobGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_list_logs_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_list_logs_input_type.rs new file mode 100644 index 00000000..14b60c73 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_list_logs_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobListLogsInput { + pub job_id: String, + pub owner_user_id: String, + pub after_sequence: Option, + pub limit: u32, +} + +impl __sdk::InModule for WebProjectRuntimeJobListLogsInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_list_open_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_list_open_input_type.rs new file mode 100644 index 00000000..64c1ce1e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_list_open_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobListOpenInput { + pub project_id: String, + pub owner_user_id: String, + pub limit: u32, +} + +impl __sdk::InModule for WebProjectRuntimeJobListOpenInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_log_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_log_row_type.rs new file mode 100644 index 00000000..5ee94e1c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_log_row_type.rs @@ -0,0 +1,76 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobLogRow { + pub log_id: String, + pub job_id: String, + pub project_id: String, + pub owner_user_id: String, + pub sequence: u64, + pub level: String, + pub message: String, + pub created_at: __sdk::Timestamp, +} + +impl __sdk::InModule for WebProjectRuntimeJobLogRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `WebProjectRuntimeJobLogRow`. +/// +/// Provides typed access to columns for query building. +pub struct WebProjectRuntimeJobLogRowCols { + pub log_id: __sdk::__query_builder::Col, + pub job_id: __sdk::__query_builder::Col, + pub project_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub sequence: __sdk::__query_builder::Col, + pub level: __sdk::__query_builder::Col, + pub message: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for WebProjectRuntimeJobLogRow { + type Cols = WebProjectRuntimeJobLogRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + WebProjectRuntimeJobLogRowCols { + log_id: __sdk::__query_builder::Col::new(table_name, "log_id"), + job_id: __sdk::__query_builder::Col::new(table_name, "job_id"), + project_id: __sdk::__query_builder::Col::new(table_name, "project_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + sequence: __sdk::__query_builder::Col::new(table_name, "sequence"), + level: __sdk::__query_builder::Col::new(table_name, "level"), + message: __sdk::__query_builder::Col::new(table_name, "message"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + } + } +} + +/// Indexed column accessor struct for the table `WebProjectRuntimeJobLogRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct WebProjectRuntimeJobLogRowIxCols { + pub job_id: __sdk::__query_builder::IxCol, + pub log_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, + pub project_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for WebProjectRuntimeJobLogRow { + type IxCols = WebProjectRuntimeJobLogRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + WebProjectRuntimeJobLogRowIxCols { + job_id: __sdk::__query_builder::IxCol::new(table_name, "job_id"), + log_id: __sdk::__query_builder::IxCol::new(table_name, "log_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + project_id: __sdk::__query_builder::IxCol::new(table_name, "project_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for WebProjectRuntimeJobLogRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_log_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_log_snapshot_type.rs new file mode 100644 index 00000000..8d7ee0f5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_log_snapshot_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobLogSnapshot { + pub log_id: String, + pub job_id: String, + pub project_id: String, + pub owner_user_id: String, + pub sequence: u64, + pub level: String, + pub message: String, + pub created_at_micros: i64, +} + +impl __sdk::InModule for WebProjectRuntimeJobLogSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_log_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_log_table.rs new file mode 100644 index 00000000..1fd8fe97 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_log_table.rs @@ -0,0 +1,166 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::web_project_runtime_job_log_row_type::WebProjectRuntimeJobLogRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `web_project_runtime_job_log`. +/// +/// Obtain a handle from the [`WebProjectRuntimeJobLogTableAccess::web_project_runtime_job_log`] method on [`super::RemoteTables`], +/// like `ctx.db.web_project_runtime_job_log()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.web_project_runtime_job_log().on_insert(...)`. +pub struct WebProjectRuntimeJobLogTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `web_project_runtime_job_log`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait WebProjectRuntimeJobLogTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`WebProjectRuntimeJobLogTableHandle`], which mediates access to the table `web_project_runtime_job_log`. + fn web_project_runtime_job_log(&self) -> WebProjectRuntimeJobLogTableHandle<'_>; +} + +impl WebProjectRuntimeJobLogTableAccess for super::RemoteTables { + fn web_project_runtime_job_log(&self) -> WebProjectRuntimeJobLogTableHandle<'_> { + WebProjectRuntimeJobLogTableHandle { + imp: self + .imp + .get_table::("web_project_runtime_job_log"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct WebProjectRuntimeJobLogInsertCallbackId(__sdk::CallbackId); +pub struct WebProjectRuntimeJobLogDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for WebProjectRuntimeJobLogTableHandle<'ctx> { + type Row = WebProjectRuntimeJobLogRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = WebProjectRuntimeJobLogInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> WebProjectRuntimeJobLogInsertCallbackId { + WebProjectRuntimeJobLogInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: WebProjectRuntimeJobLogInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = WebProjectRuntimeJobLogDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> WebProjectRuntimeJobLogDeleteCallbackId { + WebProjectRuntimeJobLogDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: WebProjectRuntimeJobLogDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct WebProjectRuntimeJobLogUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for WebProjectRuntimeJobLogTableHandle<'ctx> { + type UpdateCallbackId = WebProjectRuntimeJobLogUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> WebProjectRuntimeJobLogUpdateCallbackId { + WebProjectRuntimeJobLogUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: WebProjectRuntimeJobLogUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `log_id` unique index on the table `web_project_runtime_job_log`, +/// which allows point queries on the field of the same name +/// via the [`WebProjectRuntimeJobLogLogIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.web_project_runtime_job_log().log_id().find(...)`. +pub struct WebProjectRuntimeJobLogLogIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> WebProjectRuntimeJobLogTableHandle<'ctx> { + /// Get a handle on the `log_id` unique index on the table `web_project_runtime_job_log`. + pub fn log_id(&self) -> WebProjectRuntimeJobLogLogIdUnique<'ctx> { + WebProjectRuntimeJobLogLogIdUnique { + imp: self.imp.get_unique_constraint::("log_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> WebProjectRuntimeJobLogLogIdUnique<'ctx> { + /// Find the subscribed row whose `log_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("web_project_runtime_job_log"); + _table.add_unique_constraint::("log_id", |row| &row.log_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `WebProjectRuntimeJobLogRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait web_project_runtime_job_logQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `WebProjectRuntimeJobLogRow`. + fn web_project_runtime_job_log( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl web_project_runtime_job_logQueryTableAccess for __sdk::QueryTableAccessor { + fn web_project_runtime_job_log( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("web_project_runtime_job_log") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_preview_build_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_preview_build_procedure_result_type.rs new file mode 100644 index 00000000..5edec793 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_preview_build_procedure_result_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::web_project_preview_build_snapshot_type::WebProjectPreviewBuildSnapshot; +use super::web_project_project_snapshot_type::WebProjectProjectSnapshot; +use super::web_project_runtime_job_snapshot_type::WebProjectRuntimeJobSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobPreviewBuildProcedureResult { + pub ok: bool, + pub project: Option, + pub build: Option, + pub job: Option, + pub error_message: Option, +} + +impl __sdk::InModule for WebProjectRuntimeJobPreviewBuildProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_procedure_result_type.rs new file mode 100644 index 00000000..451cd603 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_procedure_result_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::web_project_runtime_job_log_snapshot_type::WebProjectRuntimeJobLogSnapshot; +use super::web_project_runtime_job_snapshot_type::WebProjectRuntimeJobSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobProcedureResult { + pub ok: bool, + pub job: Option, + pub jobs: Vec, + pub log: Option, + pub logs: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for WebProjectRuntimeJobProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_renew_lease_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_renew_lease_input_type.rs new file mode 100644 index 00000000..a224cc77 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_renew_lease_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobRenewLeaseInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub lease_expires_at_micros: i64, + pub renewed_at_micros: i64, +} + +impl __sdk::InModule for WebProjectRuntimeJobRenewLeaseInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_row_type.rs new file mode 100644 index 00000000..b0723664 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_row_type.rs @@ -0,0 +1,116 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobRow { + pub job_id: String, + pub project_id: String, + pub snapshot_id: String, + pub owner_user_id: String, + pub job_kind: String, + pub status: String, + pub attempt: u32, + pub worker_id: Option, + pub lease_token: Option, + pub lease_expires_at: Option<__sdk::Timestamp>, + pub cancel_requested_at: Option<__sdk::Timestamp>, + pub stale_reason: Option, + pub artifact_id: Option, + pub preview_build_id: Option, + pub error_summary: Option, + pub created_at: __sdk::Timestamp, + pub started_at: Option<__sdk::Timestamp>, + pub finished_at: Option<__sdk::Timestamp>, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for WebProjectRuntimeJobRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `WebProjectRuntimeJobRow`. +/// +/// Provides typed access to columns for query building. +pub struct WebProjectRuntimeJobRowCols { + pub job_id: __sdk::__query_builder::Col, + pub project_id: __sdk::__query_builder::Col, + pub snapshot_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub job_kind: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub attempt: __sdk::__query_builder::Col, + pub worker_id: __sdk::__query_builder::Col>, + pub lease_token: __sdk::__query_builder::Col>, + pub lease_expires_at: + __sdk::__query_builder::Col>, + pub cancel_requested_at: + __sdk::__query_builder::Col>, + pub stale_reason: __sdk::__query_builder::Col>, + pub artifact_id: __sdk::__query_builder::Col>, + pub preview_build_id: __sdk::__query_builder::Col>, + pub error_summary: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, + pub started_at: __sdk::__query_builder::Col>, + pub finished_at: __sdk::__query_builder::Col>, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for WebProjectRuntimeJobRow { + type Cols = WebProjectRuntimeJobRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + WebProjectRuntimeJobRowCols { + job_id: __sdk::__query_builder::Col::new(table_name, "job_id"), + project_id: __sdk::__query_builder::Col::new(table_name, "project_id"), + snapshot_id: __sdk::__query_builder::Col::new(table_name, "snapshot_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + job_kind: __sdk::__query_builder::Col::new(table_name, "job_kind"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + attempt: __sdk::__query_builder::Col::new(table_name, "attempt"), + worker_id: __sdk::__query_builder::Col::new(table_name, "worker_id"), + lease_token: __sdk::__query_builder::Col::new(table_name, "lease_token"), + lease_expires_at: __sdk::__query_builder::Col::new(table_name, "lease_expires_at"), + cancel_requested_at: __sdk::__query_builder::Col::new( + table_name, + "cancel_requested_at", + ), + stale_reason: __sdk::__query_builder::Col::new(table_name, "stale_reason"), + artifact_id: __sdk::__query_builder::Col::new(table_name, "artifact_id"), + preview_build_id: __sdk::__query_builder::Col::new(table_name, "preview_build_id"), + error_summary: __sdk::__query_builder::Col::new(table_name, "error_summary"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + started_at: __sdk::__query_builder::Col::new(table_name, "started_at"), + finished_at: __sdk::__query_builder::Col::new(table_name, "finished_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `WebProjectRuntimeJobRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct WebProjectRuntimeJobRowIxCols { + pub job_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, + pub project_id: __sdk::__query_builder::IxCol, + pub snapshot_id: __sdk::__query_builder::IxCol, + pub status: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for WebProjectRuntimeJobRow { + type IxCols = WebProjectRuntimeJobRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + WebProjectRuntimeJobRowIxCols { + job_id: __sdk::__query_builder::IxCol::new(table_name, "job_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + project_id: __sdk::__query_builder::IxCol::new(table_name, "project_id"), + snapshot_id: __sdk::__query_builder::IxCol::new(table_name, "snapshot_id"), + status: __sdk::__query_builder::IxCol::new(table_name, "status"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for WebProjectRuntimeJobRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_snapshot_type.rs new file mode 100644 index 00000000..a9425739 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_snapshot_type.rs @@ -0,0 +1,33 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobSnapshot { + pub job_id: String, + pub project_id: String, + pub snapshot_id: String, + pub owner_user_id: String, + pub job_kind: String, + pub status: String, + pub attempt: u32, + pub worker_id: Option, + pub lease_token: Option, + pub lease_expires_at_micros: Option, + pub cancel_requested_at_micros: Option, + pub stale_reason: Option, + pub artifact_id: Option, + pub preview_build_id: Option, + pub error_summary: Option, + pub created_at_micros: i64, + pub started_at_micros: Option, + pub finished_at_micros: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for WebProjectRuntimeJobSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_stale_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_stale_input_type.rs new file mode 100644 index 00000000..c7aed1dc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_stale_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WebProjectRuntimeJobStaleInput { + pub job_id: String, + pub owner_user_id: String, + pub stale_reason: String, + pub stale_at_micros: i64, +} + +impl __sdk::InModule for WebProjectRuntimeJobStaleInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_table.rs new file mode 100644 index 00000000..f1fceca9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/web_project_runtime_job_table.rs @@ -0,0 +1,162 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::web_project_runtime_job_row_type::WebProjectRuntimeJobRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `web_project_runtime_job`. +/// +/// Obtain a handle from the [`WebProjectRuntimeJobTableAccess::web_project_runtime_job`] method on [`super::RemoteTables`], +/// like `ctx.db.web_project_runtime_job()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.web_project_runtime_job().on_insert(...)`. +pub struct WebProjectRuntimeJobTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `web_project_runtime_job`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait WebProjectRuntimeJobTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`WebProjectRuntimeJobTableHandle`], which mediates access to the table `web_project_runtime_job`. + fn web_project_runtime_job(&self) -> WebProjectRuntimeJobTableHandle<'_>; +} + +impl WebProjectRuntimeJobTableAccess for super::RemoteTables { + fn web_project_runtime_job(&self) -> WebProjectRuntimeJobTableHandle<'_> { + WebProjectRuntimeJobTableHandle { + imp: self + .imp + .get_table::("web_project_runtime_job"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct WebProjectRuntimeJobInsertCallbackId(__sdk::CallbackId); +pub struct WebProjectRuntimeJobDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for WebProjectRuntimeJobTableHandle<'ctx> { + type Row = WebProjectRuntimeJobRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = WebProjectRuntimeJobInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> WebProjectRuntimeJobInsertCallbackId { + WebProjectRuntimeJobInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: WebProjectRuntimeJobInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = WebProjectRuntimeJobDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> WebProjectRuntimeJobDeleteCallbackId { + WebProjectRuntimeJobDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: WebProjectRuntimeJobDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct WebProjectRuntimeJobUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for WebProjectRuntimeJobTableHandle<'ctx> { + type UpdateCallbackId = WebProjectRuntimeJobUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> WebProjectRuntimeJobUpdateCallbackId { + WebProjectRuntimeJobUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: WebProjectRuntimeJobUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `job_id` unique index on the table `web_project_runtime_job`, +/// which allows point queries on the field of the same name +/// via the [`WebProjectRuntimeJobJobIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.web_project_runtime_job().job_id().find(...)`. +pub struct WebProjectRuntimeJobJobIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> WebProjectRuntimeJobTableHandle<'ctx> { + /// Get a handle on the `job_id` unique index on the table `web_project_runtime_job`. + pub fn job_id(&self) -> WebProjectRuntimeJobJobIdUnique<'ctx> { + WebProjectRuntimeJobJobIdUnique { + imp: self.imp.get_unique_constraint::("job_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> WebProjectRuntimeJobJobIdUnique<'ctx> { + /// Find the subscribed row whose `job_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("web_project_runtime_job"); + _table.add_unique_constraint::("job_id", |row| &row.job_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `WebProjectRuntimeJobRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait web_project_runtime_jobQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `WebProjectRuntimeJobRow`. + fn web_project_runtime_job(&self) -> __sdk::__query_builder::Table; +} + +impl web_project_runtime_jobQueryTableAccess for __sdk::QueryTableAccessor { + fn web_project_runtime_job(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("web_project_runtime_job") + } +} diff --git a/server-rs/crates/spacetime-client/src/web_project.rs b/server-rs/crates/spacetime-client/src/web_project.rs index 0f711543..4515af93 100644 --- a/server-rs/crates/spacetime-client/src/web_project.rs +++ b/server-rs/crates/spacetime-client/src/web_project.rs @@ -190,4 +190,331 @@ impl SpacetimeClient { ) .await } + + pub async fn create_web_project_runtime_job( + &self, + input: WebProjectRuntimeJobCreateRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "create_web_project_runtime_job_and_return", + move |connection, sender| { + connection + .procedures() + .create_web_project_runtime_job_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_web_project_runtime_job_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn get_web_project_runtime_job( + &self, + input: WebProjectRuntimeJobGetRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "get_web_project_runtime_job_and_return", + move |connection, sender| { + connection + .procedures() + .get_web_project_runtime_job_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_web_project_runtime_job_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn list_open_web_project_runtime_jobs( + &self, + input: WebProjectRuntimeJobListOpenRecordInput, + ) -> Result, SpacetimeClientError> { + let procedure_input = input.into(); + + self.call_after_connect( + "list_open_web_project_runtime_jobs_and_return", + move |connection, sender| { + connection + .procedures() + .list_open_web_project_runtime_jobs_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_web_project_runtime_job_list_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn claim_web_project_runtime_jobs( + &self, + input: WebProjectRuntimeJobClaimRecordInput, + ) -> Result, SpacetimeClientError> { + let procedure_input = input.into(); + + self.call_after_connect( + "claim_web_project_runtime_jobs_and_return", + move |connection, sender| { + connection + .procedures() + .claim_web_project_runtime_jobs_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_web_project_runtime_job_list_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn renew_web_project_runtime_job_lease( + &self, + input: WebProjectRuntimeJobRenewLeaseRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "renew_web_project_runtime_job_lease_and_return", + move |connection, sender| { + connection + .procedures() + .renew_web_project_runtime_job_lease_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_web_project_runtime_job_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn complete_web_project_runtime_job( + &self, + input: WebProjectRuntimeJobCompleteRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "complete_web_project_runtime_job_and_return", + move |connection, sender| { + connection + .procedures() + .complete_web_project_runtime_job_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_web_project_runtime_job_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn complete_web_project_preview_build_runtime_job( + &self, + input: WebProjectRuntimeJobCompletePreviewBuildRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "complete_web_project_preview_build_runtime_job_and_return", + move |connection, sender| { + connection + .procedures() + .complete_web_project_preview_build_runtime_job_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_web_project_runtime_job_preview_build_procedure_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn fail_web_project_runtime_job( + &self, + input: WebProjectRuntimeJobFailRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "fail_web_project_runtime_job_and_return", + move |connection, sender| { + connection + .procedures() + .fail_web_project_runtime_job_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_web_project_runtime_job_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn cancel_web_project_runtime_job( + &self, + input: WebProjectRuntimeJobCancelRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "cancel_web_project_runtime_job_and_return", + move |connection, sender| { + connection + .procedures() + .cancel_web_project_runtime_job_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_web_project_runtime_job_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn mark_web_project_runtime_job_stale( + &self, + input: WebProjectRuntimeJobStaleRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "mark_web_project_runtime_job_stale_and_return", + move |connection, sender| { + connection + .procedures() + .mark_web_project_runtime_job_stale_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_web_project_runtime_job_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn expire_web_project_runtime_job( + &self, + input: WebProjectRuntimeJobExpireRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "expire_web_project_runtime_job_and_return", + move |connection, sender| { + connection + .procedures() + .expire_web_project_runtime_job_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_web_project_runtime_job_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn append_web_project_runtime_job_log( + &self, + input: WebProjectRuntimeJobAppendLogRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "append_web_project_runtime_job_log_and_return", + move |connection, sender| { + connection + .procedures() + .append_web_project_runtime_job_log_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_web_project_runtime_job_log_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn list_web_project_runtime_job_logs( + &self, + input: WebProjectRuntimeJobListLogsRecordInput, + ) -> Result, SpacetimeClientError> { + let procedure_input = input.into(); + + self.call_after_connect( + "list_web_project_runtime_job_logs_and_return", + move |connection, sender| { + connection + .procedures() + .list_web_project_runtime_job_logs_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_web_project_runtime_job_log_list_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } } diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index 5c1f0b48..76b57ea0 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -2,6 +2,12 @@ // 少数领域 helper 同名只影响 value namespace 导出,不影响 table / reducer 类型。 #![allow(ambiguous_glob_reexports)] +// 原生 `cargo test` 时,spacetimedb-bindings-sys 的 WASM 宿主符号无法由链接器解析。 +// 仅在非 WASM 测试编译中引入桩实现,满足链接器需求。 +// 这些桩函数不会在实际测试中被调用(单元测试只验证纯逻辑,不操作 SpacetimeDB 表)。 +#[cfg(all(test, not(target_arch = "wasm32")))] +mod test_stubs; + pub use module_ai::*; pub use module_assets::*; pub use module_big_fish::*; diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 38dc8610..be4aa3ae 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -31,7 +31,10 @@ use crate::square_hole::tables::{ square_hole_agent_message, square_hole_agent_session, square_hole_runtime_run, square_hole_work_profile, }; -use crate::web_project_storage::{web_project, web_project_preview_build, web_project_snapshot}; +use crate::web_project_storage::{ + web_project, web_project_preview_build, web_project_runtime_job, web_project_runtime_job_log, + web_project_snapshot, +}; use crate::wooden_fish::tables::{ wooden_fish_agent_session, wooden_fish_event, wooden_fish_runtime_run, wooden_fish_work_profile, }; @@ -238,6 +241,8 @@ macro_rules! migration_tables { web_project, web_project_snapshot, web_project_preview_build, + web_project_runtime_job, + web_project_runtime_job_log, // web_project_service_identity 是环境级服务授权表,不随业务迁移包导入导出。 puzzle_agent_session, puzzle_background_compile_task, diff --git a/server-rs/crates/spacetime-module/src/test_stubs.rs b/server-rs/crates/spacetime-module/src/test_stubs.rs new file mode 100644 index 00000000..a9303376 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/test_stubs.rs @@ -0,0 +1,265 @@ +//! 原生(非 WASM)编译时的 SpacetimeDB ABI 桩实现。 +//! +//! ## 背景 +//! `spacetimedb-bindings-sys` 通过 `#[link(wasm_import_module = "spacetime_10.X")]` +//! 声明了所有宿主 ABI 符号。这些符号由 SpacetimeDB WASM 运行时在 WASM 环境中注入。 +//! 当 `cargo test` 编译原生测试二进制时,没有 WASM 宿主,链接器无法找到这些符号。 +//! +//! ## 作用 +//! 本模块为全部 25 个 ABI 符号提供 `#[unsafe(no_mangle)] extern "C"` 桩实现, +//! 仅满足链接器需求,不会在测试中被实际调用。 +//! (spacetime-module 的单元测试只测试纯逻辑函数,不访问 SpacetimeDB 表。) +//! +//! ## 类型说明 +//! spacetimedb_primitives 中: +//! TableId = repr(transparent) struct over u32 +//! IndexId = repr(transparent) struct over u32 +//! ColId = repr(transparent) struct over u16 +//! spacetimedb_bindings_sys::raw 中: +//! RowIter = repr(transparent) struct over u32 +//! BytesSource = repr(transparent) struct over u32 +//! BytesSink = repr(transparent) struct over u32 + +#![allow(unused_variables, non_snake_case)] + +// ── 类型别名,与 spacetimedb ABI 签名对齐 ──────────────────────────────────── + +type TableId = u32; +type IndexId = u32; +/// ColId 在 spacetimedb_primitives 中为 u16 +type ColId = u16; +type RowIter = u32; +type BytesSource = u32; +type BytesSink = u32; + +// ── 表 / 索引查找 ───────────────────────────────────────────────────────────── + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn table_id_from_name( + name_ptr: *const u8, + name_len: usize, + out: *mut TableId, +) -> u16 { + panic!("SpacetimeDB WASM ABI 'table_id_from_name' 不可在原生单元测试中调用") +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn index_id_from_name( + name_ptr: *const u8, + name_len: usize, + out: *mut IndexId, +) -> u16 { + panic!("SpacetimeDB WASM ABI 'index_id_from_name' 不可在原生单元测试中调用") +} + +// ── 表行数 ──────────────────────────────────────────────────────────────────── + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn datastore_table_row_count(table_id: TableId, out: *mut u64) -> u16 { + panic!("SpacetimeDB WASM ABI 'datastore_table_row_count' 不可在原生单元测试中调用") +} + +// ── 全表 / 索引扫描 ─────────────────────────────────────────────────────────── + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn datastore_table_scan_bsatn( + table_id: TableId, + out: *mut RowIter, +) -> u16 { + panic!("SpacetimeDB WASM ABI 'datastore_table_scan_bsatn' 不可在原生单元测试中调用") +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn datastore_index_scan_point_bsatn( + index_id: IndexId, + point_ptr: *const u8, + point_len: usize, + out: *mut RowIter, +) -> u16 { + panic!("SpacetimeDB WASM ABI 'datastore_index_scan_point_bsatn' 不可在原生单元测试中调用") +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn datastore_index_scan_range_bsatn( + index_id: IndexId, + prefix_ptr: *const u8, + prefix_len: usize, + prefix_elems: ColId, + rstart_ptr: *const u8, + rstart_len: usize, + rend_ptr: *const u8, + rend_len: usize, + out: *mut RowIter, +) -> u16 { + panic!("SpacetimeDB WASM ABI 'datastore_index_scan_range_bsatn' 不可在原生单元测试中调用") +} + +// ── 按索引删除 ──────────────────────────────────────────────────────────────── + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn datastore_delete_by_index_scan_point_bsatn( + index_id: IndexId, + point_ptr: *const u8, + point_len: usize, + out: *mut u32, +) -> u16 { + panic!( + "SpacetimeDB WASM ABI 'datastore_delete_by_index_scan_point_bsatn' 不可在原生单元测试中调用" + ) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn datastore_delete_by_index_scan_range_bsatn( + index_id: IndexId, + prefix_ptr: *const u8, + prefix_len: usize, + prefix_elems: ColId, + rstart_ptr: *const u8, + rstart_len: usize, + rend_ptr: *const u8, + rend_len: usize, + out: *mut u32, +) -> u16 { + panic!( + "SpacetimeDB WASM ABI 'datastore_delete_by_index_scan_range_bsatn' 不可在原生单元测试中调用" + ) +} + +// ── 行写入 / 删除 ───────────────────────────────────────────────────────────── + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn datastore_insert_bsatn( + table_id: TableId, + row_ptr: *mut u8, + row_len_ptr: *mut usize, +) -> u16 { + panic!("SpacetimeDB WASM ABI 'datastore_insert_bsatn' 不可在原生单元测试中调用") +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn datastore_update_bsatn( + table_id: TableId, + index_id: IndexId, + row_ptr: *mut u8, + row_len_ptr: *mut usize, +) -> u16 { + panic!("SpacetimeDB WASM ABI 'datastore_update_bsatn' 不可在原生单元测试中调用") +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn datastore_delete_all_by_eq_bsatn( + table_id: TableId, + rel_ptr: *const u8, + rel_len: usize, + out: *mut u32, +) -> u16 { + panic!("SpacetimeDB WASM ABI 'datastore_delete_all_by_eq_bsatn' 不可在原生单元测试中调用") +} + +// ── 行迭代器 ────────────────────────────────────────────────────────────────── + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn row_iter_bsatn_advance( + iter: RowIter, + buffer_ptr: *mut u8, + buffer_len_ptr: *mut usize, +) -> i16 { + panic!("SpacetimeDB WASM ABI 'row_iter_bsatn_advance' 不可在原生单元测试中调用") +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn row_iter_bsatn_close(iter: RowIter) -> u16 { + panic!("SpacetimeDB WASM ABI 'row_iter_bsatn_close' 不可在原生单元测试中调用") +} + +// ── 字节流 ──────────────────────────────────────────────────────────────────── + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn bytes_sink_write( + sink: BytesSink, + buffer_ptr: *const u8, + buffer_len_ptr: *mut usize, +) -> u16 { + panic!("SpacetimeDB WASM ABI 'bytes_sink_write' 不可在原生单元测试中调用") +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn bytes_source_remaining_length( + source: BytesSource, + out: *mut u32, +) -> i16 { + panic!("SpacetimeDB WASM ABI 'bytes_source_remaining_length' 不可在原生单元测试中调用") +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn bytes_source_read( + source: BytesSource, + buffer_ptr: *mut u8, + buffer_len_ptr: *mut usize, +) -> i16 { + panic!("SpacetimeDB WASM ABI 'bytes_source_read' 不可在原生单元测试中调用") +} + +// ── 日志 ────────────────────────────────────────────────────────────────────── + +/// console_log 在测试中静默丢弃,不 panic——日志初始化可能在 crate 加载时触发。 +#[unsafe(no_mangle)] +pub unsafe extern "C" fn console_log( + level: u8, + target_ptr: *const u8, + target_len: usize, + filename_ptr: *const u8, + filename_len: usize, + line_number: u32, + message_ptr: *const u8, + message_len: usize, +) { + // 原生测试中静默丢弃日志消息 +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn console_timer_start(name_ptr: *const u8, name_len: usize) -> u32 { + panic!("SpacetimeDB WASM ABI 'console_timer_start' 不可在原生单元测试中调用") +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn console_timer_end(timer_id: u32) -> u16 { + panic!("SpacetimeDB WASM ABI 'console_timer_end' 不可在原生单元测试中调用") +} + +// ── 模块身份 / JWT ──────────────────────────────────────────────────────────── + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn identity(out_ptr: *mut u8) { + panic!("SpacetimeDB WASM ABI 'identity' 不可在原生单元测试中调用") +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn get_jwt( + connection_id_ptr: *const u8, + bytes_source_id: *mut BytesSource, +) -> u16 { + panic!("SpacetimeDB WASM ABI 'get_jwt' 不可在原生单元测试中调用") +} + +// ── Procedure(事务 / 睡眠)────────────────────────────────────────────────── + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn procedure_sleep_until(wake_at_micros_since_unix_epoch: i64) -> i64 { + panic!("SpacetimeDB WASM ABI 'procedure_sleep_until' 不可在原生单元测试中调用") +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn procedure_start_mut_tx(out: *mut i64) -> u16 { + panic!("SpacetimeDB WASM ABI 'procedure_start_mut_tx' 不可在原生单元测试中调用") +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn procedure_commit_mut_tx() -> u16 { + panic!("SpacetimeDB WASM ABI 'procedure_commit_mut_tx' 不可在原生单元测试中调用") +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn procedure_abort_mut_tx() -> u16 { + panic!("SpacetimeDB WASM ABI 'procedure_abort_mut_tx' 不可在原生单元测试中调用") +} diff --git a/server-rs/crates/spacetime-module/src/web_project.rs b/server-rs/crates/spacetime-module/src/web_project.rs index f955385a..f75f3a0c 100644 --- a/server-rs/crates/spacetime-module/src/web_project.rs +++ b/server-rs/crates/spacetime-module/src/web_project.rs @@ -10,6 +10,18 @@ const WEB_PROJECT_MAX_FILE_BYTES: usize = 128 * 1024; const WEB_PROJECT_MAX_SNAPSHOT_BYTES: usize = 512 * 1024; const WEB_PROJECT_MAX_LOG_COUNT: usize = 200; const WEB_PROJECT_MAX_LOG_BYTES: usize = 128 * 1024; +const WEB_PROJECT_RUNTIME_JOB_KIND_PREVIEW_BUILD: &str = "preview_build"; +const WEB_PROJECT_RUNTIME_JOB_STATUS_QUEUED: &str = "queued"; +const WEB_PROJECT_RUNTIME_JOB_STATUS_RUNNING: &str = "running"; +const WEB_PROJECT_RUNTIME_JOB_STATUS_SUCCEEDED: &str = "succeeded"; +const WEB_PROJECT_RUNTIME_JOB_STATUS_FAILED: &str = "failed"; +const WEB_PROJECT_RUNTIME_JOB_STATUS_CANCELLED: &str = "cancelled"; +const WEB_PROJECT_RUNTIME_JOB_STATUS_EXPIRED: &str = "expired"; +const WEB_PROJECT_RUNTIME_JOB_STATUS_STALE: &str = "stale"; +const WEB_PROJECT_RUNTIME_JOB_MAX_ERROR_CHARS: usize = 500; +const WEB_PROJECT_RUNTIME_JOB_MAX_STALE_REASON_CHARS: usize = 300; +const WEB_PROJECT_RUNTIME_JOB_LOG_MAX_LEVEL_CHARS: usize = 24; +const WEB_PROJECT_RUNTIME_JOB_LOG_MAX_MESSAGE_CHARS: usize = 2000; const WEB_PROJECT_SERVICE_MAX_NOTE_CHARS: usize = 160; const WEB_PROJECT_SERVICE_MIN_BOOTSTRAP_SECRET_LEN: usize = 16; const WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET: Option<&str> = @@ -73,6 +85,56 @@ pub struct WebProjectPreviewBuildRow { updated_at: Timestamp, } +#[spacetimedb::table( + accessor = web_project_runtime_job, + index(accessor = by_web_project_runtime_job_project_id, btree(columns = [project_id])), + index(accessor = by_web_project_runtime_job_snapshot_id, btree(columns = [snapshot_id])), + index(accessor = by_web_project_runtime_job_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_web_project_runtime_job_status, btree(columns = [status])) +)] +#[derive(Clone)] +pub struct WebProjectRuntimeJobRow { + #[primary_key] + job_id: String, + project_id: String, + snapshot_id: String, + owner_user_id: String, + job_kind: String, + status: String, + attempt: u32, + worker_id: Option, + lease_token: Option, + lease_expires_at: Option, + cancel_requested_at: Option, + stale_reason: Option, + artifact_id: Option, + preview_build_id: Option, + error_summary: Option, + created_at: Timestamp, + started_at: Option, + finished_at: Option, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = web_project_runtime_job_log, + index(accessor = by_web_project_runtime_job_log_job_id, btree(columns = [job_id])), + index(accessor = by_web_project_runtime_job_log_project_id, btree(columns = [project_id])), + index(accessor = by_web_project_runtime_job_log_owner_user_id, btree(columns = [owner_user_id])) +)] +#[derive(Clone)] +pub struct WebProjectRuntimeJobLogRow { + #[primary_key] + log_id: String, + job_id: String, + project_id: String, + owner_user_id: String, + sequence: u64, + level: String, + message: String, + created_at: Timestamp, +} + #[spacetimedb::table(accessor = web_project_service_identity)] pub struct WebProjectServiceIdentity { #[primary_key] @@ -134,6 +196,41 @@ pub struct WebProjectPreviewBuildSnapshot { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WebProjectRuntimeJobSnapshot { + pub job_id: String, + pub project_id: String, + pub snapshot_id: String, + pub owner_user_id: String, + pub job_kind: String, + pub status: String, + pub attempt: u32, + pub worker_id: Option, + pub lease_token: Option, + pub lease_expires_at_micros: Option, + pub cancel_requested_at_micros: Option, + pub stale_reason: Option, + pub artifact_id: Option, + pub preview_build_id: Option, + pub error_summary: Option, + pub created_at_micros: i64, + pub started_at_micros: Option, + pub finished_at_micros: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WebProjectRuntimeJobLogSnapshot { + pub log_id: String, + pub job_id: String, + pub project_id: String, + pub owner_user_id: String, + pub sequence: u64, + pub level: String, + pub message: String, + pub created_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct WebProjectCreateInput { pub project_id: String, @@ -204,6 +301,123 @@ pub struct WebProjectPreviewBuildUpdateInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WebProjectRuntimeJobCreateInput { + pub job_id: String, + pub project_id: String, + pub snapshot_id: String, + pub owner_user_id: String, + pub job_kind: String, + pub preview_build_id: Option, + pub now_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WebProjectRuntimeJobGetInput { + pub job_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WebProjectRuntimeJobListOpenInput { + pub project_id: String, + pub owner_user_id: String, + pub limit: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WebProjectRuntimeJobClaimInput { + pub worker_id: String, + pub limit: u32, + pub lease_expires_at_micros: i64, + pub claimed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WebProjectRuntimeJobRenewLeaseInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub lease_expires_at_micros: i64, + pub renewed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WebProjectRuntimeJobCompleteInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub artifact_id: Option, + pub preview_build_id: Option, + pub completed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WebProjectRuntimeJobCompletePreviewBuildInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub preview_build_id: String, + pub artifact_id: String, + pub preview_token_id: String, + pub preview_url: String, + pub logs: Vec, + pub started_at_micros: Option, + pub finished_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WebProjectRuntimeJobFailInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub error_summary: String, + pub failed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WebProjectRuntimeJobCancelInput { + pub job_id: String, + pub owner_user_id: String, + pub cancelled_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WebProjectRuntimeJobStaleInput { + pub job_id: String, + pub owner_user_id: String, + pub stale_reason: String, + pub stale_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WebProjectRuntimeJobExpireInput { + pub job_id: String, + pub owner_user_id: String, + pub expired_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WebProjectRuntimeJobAppendLogInput { + pub log_id: String, + pub job_id: String, + pub owner_user_id: String, + pub sequence: u64, + pub level: String, + pub message: String, + pub worker_id: Option, + pub lease_token: Option, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WebProjectRuntimeJobListLogsInput { + pub job_id: String, + pub owner_user_id: String, + pub after_sequence: Option, + pub limit: u32, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct WebProjectServiceIdentityAuthorizeInput { pub bootstrap_secret: String, @@ -239,6 +453,25 @@ pub struct WebProjectPreviewBuildProcedureResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WebProjectRuntimeJobProcedureResult { + pub ok: bool, + pub job: Option, + pub jobs: Vec, + pub log: Option, + pub logs: Vec, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WebProjectRuntimeJobPreviewBuildProcedureResult { + pub ok: bool, + pub project: Option, + pub build: Option, + pub job: Option, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct WebProjectServiceIdentityProcedureResult { pub ok: bool, @@ -375,6 +608,224 @@ pub fn update_web_project_preview_build_and_return( } } +#[spacetimedb::procedure] +pub fn create_web_project_runtime_job_and_return( + ctx: &mut ProcedureContext, + input: WebProjectRuntimeJobCreateInput, +) -> WebProjectRuntimeJobProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_web_project_service_identity(tx, caller)?; + create_web_project_runtime_job(tx, input.clone()) + }) { + Ok(job) => web_project_runtime_job_ok(Some(job)), + Err(message) => web_project_runtime_job_error(message), + } +} + +#[spacetimedb::procedure] +pub fn get_web_project_runtime_job_and_return( + ctx: &mut ProcedureContext, + input: WebProjectRuntimeJobGetInput, +) -> WebProjectRuntimeJobProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_web_project_service_identity(tx, caller)?; + get_web_project_runtime_job(tx, input.clone()) + }) { + Ok(job) => web_project_runtime_job_ok(Some(job)), + Err(message) => web_project_runtime_job_error(message), + } +} + +#[spacetimedb::procedure] +pub fn list_open_web_project_runtime_jobs_and_return( + ctx: &mut ProcedureContext, + input: WebProjectRuntimeJobListOpenInput, +) -> WebProjectRuntimeJobProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_web_project_service_identity(tx, caller)?; + list_open_web_project_runtime_jobs(tx, input.clone()) + }) { + Ok(jobs) => WebProjectRuntimeJobProcedureResult { + ok: true, + job: None, + jobs, + log: None, + logs: Vec::new(), + error_message: None, + }, + Err(message) => web_project_runtime_job_error(message), + } +} + +#[spacetimedb::procedure] +pub fn claim_web_project_runtime_jobs_and_return( + ctx: &mut ProcedureContext, + input: WebProjectRuntimeJobClaimInput, +) -> WebProjectRuntimeJobProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_web_project_service_identity(tx, caller)?; + claim_web_project_runtime_jobs(tx, input.clone()) + }) { + Ok(jobs) => WebProjectRuntimeJobProcedureResult { + ok: true, + job: None, + jobs, + log: None, + logs: Vec::new(), + error_message: None, + }, + Err(message) => web_project_runtime_job_error(message), + } +} + +#[spacetimedb::procedure] +pub fn renew_web_project_runtime_job_lease_and_return( + ctx: &mut ProcedureContext, + input: WebProjectRuntimeJobRenewLeaseInput, +) -> WebProjectRuntimeJobProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_web_project_service_identity(tx, caller)?; + renew_web_project_runtime_job_lease(tx, input.clone()) + }) { + Ok(job) => web_project_runtime_job_ok(Some(job)), + Err(message) => web_project_runtime_job_error(message), + } +} + +#[spacetimedb::procedure] +pub fn complete_web_project_runtime_job_and_return( + ctx: &mut ProcedureContext, + input: WebProjectRuntimeJobCompleteInput, +) -> WebProjectRuntimeJobProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_web_project_service_identity(tx, caller)?; + complete_web_project_runtime_job(tx, input.clone()) + }) { + Ok(job) => web_project_runtime_job_ok(Some(job)), + Err(message) => web_project_runtime_job_error(message), + } +} + +#[spacetimedb::procedure] +pub fn complete_web_project_preview_build_runtime_job_and_return( + ctx: &mut ProcedureContext, + input: WebProjectRuntimeJobCompletePreviewBuildInput, +) -> WebProjectRuntimeJobPreviewBuildProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_web_project_service_identity(tx, caller)?; + complete_web_project_preview_build_runtime_job(tx, input.clone()) + }) { + Ok((project, build, job)) => { + web_project_runtime_job_preview_build_ok(Some(project), Some(build), Some(job)) + } + Err(message) => web_project_runtime_job_preview_build_error(message), + } +} + +#[spacetimedb::procedure] +pub fn fail_web_project_runtime_job_and_return( + ctx: &mut ProcedureContext, + input: WebProjectRuntimeJobFailInput, +) -> WebProjectRuntimeJobProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_web_project_service_identity(tx, caller)?; + fail_web_project_runtime_job(tx, input.clone()) + }) { + Ok(job) => web_project_runtime_job_ok(Some(job)), + Err(message) => web_project_runtime_job_error(message), + } +} + +#[spacetimedb::procedure] +pub fn cancel_web_project_runtime_job_and_return( + ctx: &mut ProcedureContext, + input: WebProjectRuntimeJobCancelInput, +) -> WebProjectRuntimeJobProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_web_project_service_identity(tx, caller)?; + cancel_web_project_runtime_job(tx, input.clone()) + }) { + Ok(job) => web_project_runtime_job_ok(Some(job)), + Err(message) => web_project_runtime_job_error(message), + } +} + +#[spacetimedb::procedure] +pub fn mark_web_project_runtime_job_stale_and_return( + ctx: &mut ProcedureContext, + input: WebProjectRuntimeJobStaleInput, +) -> WebProjectRuntimeJobProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_web_project_service_identity(tx, caller)?; + mark_web_project_runtime_job_stale(tx, input.clone()) + }) { + Ok(job) => web_project_runtime_job_ok(Some(job)), + Err(message) => web_project_runtime_job_error(message), + } +} + +#[spacetimedb::procedure] +pub fn expire_web_project_runtime_job_and_return( + ctx: &mut ProcedureContext, + input: WebProjectRuntimeJobExpireInput, +) -> WebProjectRuntimeJobProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_web_project_service_identity(tx, caller)?; + expire_web_project_runtime_job(tx, input.clone()) + }) { + Ok(job) => web_project_runtime_job_ok(Some(job)), + Err(message) => web_project_runtime_job_error(message), + } +} + +#[spacetimedb::procedure] +pub fn append_web_project_runtime_job_log_and_return( + ctx: &mut ProcedureContext, + input: WebProjectRuntimeJobAppendLogInput, +) -> WebProjectRuntimeJobProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_web_project_service_identity(tx, caller)?; + append_web_project_runtime_job_log(tx, input.clone()) + }) { + Ok(log) => web_project_runtime_job_log_ok(Some(log)), + Err(message) => web_project_runtime_job_error(message), + } +} + +#[spacetimedb::procedure] +pub fn list_web_project_runtime_job_logs_and_return( + ctx: &mut ProcedureContext, + input: WebProjectRuntimeJobListLogsInput, +) -> WebProjectRuntimeJobProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_web_project_service_identity(tx, caller)?; + list_web_project_runtime_job_logs(tx, input.clone()) + }) { + Ok(logs) => WebProjectRuntimeJobProcedureResult { + ok: true, + job: None, + jobs: Vec::new(), + log: None, + logs, + error_message: None, + }, + Err(message) => web_project_runtime_job_error(message), + } +} + #[spacetimedb::procedure] pub fn authorize_web_project_service_identity( ctx: &mut ProcedureContext, @@ -545,6 +996,7 @@ fn save_web_project_snapshot( created_at: now, }); mark_open_builds_stale(ctx, project_id.as_str(), now); + mark_open_runtime_jobs_stale(ctx, project_id.as_str(), now, "新 snapshot 已成为 active"); ctx.db.web_project().project_id().delete(&project_id); ctx.db.web_project().insert(WebProject { active_snapshot_id: snapshot_id.clone(), @@ -644,7 +1096,8 @@ fn get_web_project_preview_build_by_token( .iter() .find(|build| build.preview_token_id.as_deref() == Some(preview_token_id.as_str())) .ok_or_else(|| "Web 工程预览令牌不存在".to_string())?; - let project = require_owned_project(ctx, build.project_id.as_str(), build.owner_user_id.as_str())?; + let project = + require_owned_project(ctx, build.project_id.as_str(), build.owner_user_id.as_str())?; Ok(( project_record_from_row(project), build_snapshot_from_row(build)?, @@ -719,6 +1172,615 @@ fn update_web_project_preview_build( )) } +fn create_web_project_runtime_job( + ctx: &ReducerContext, + input: WebProjectRuntimeJobCreateInput, +) -> Result { + let job_id = normalize_required(&input.job_id, "web_project_runtime_job.job_id")?; + let project_id = normalize_required(&input.project_id, "web_project_runtime_job.project_id")?; + let snapshot_id = + normalize_required(&input.snapshot_id, "web_project_runtime_job.snapshot_id")?; + let owner_user_id = normalize_required( + &input.owner_user_id, + "web_project_runtime_job.owner_user_id", + )?; + let job_kind = normalize_runtime_job_kind(&input.job_kind)?; + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?; + let snapshot = require_snapshot(ctx, snapshot_id.as_str(), project_id.as_str())?; + if snapshot.owner_user_id != owner_user_id { + return Err("Web Project runtime job 快照归属不匹配".to_string()); + } + let preview_build_id = normalize_optional(input.preview_build_id); + if let Some(preview_build_id) = preview_build_id.as_deref() { + require_runtime_job_preview_build_link( + ctx, + preview_build_id, + project_id.as_str(), + snapshot_id.as_str(), + owner_user_id.as_str(), + )?; + } + if ctx + .db + .web_project_runtime_job() + .job_id() + .find(&job_id) + .is_some() + { + return Err("Web Project runtime job 已存在".to_string()); + } + + ctx.db + .web_project_runtime_job() + .insert(WebProjectRuntimeJobRow { + job_id: job_id.clone(), + project_id, + snapshot_id, + owner_user_id, + job_kind, + status: WEB_PROJECT_RUNTIME_JOB_STATUS_QUEUED.to_string(), + attempt: 0, + worker_id: None, + lease_token: None, + lease_expires_at: None, + cancel_requested_at: None, + stale_reason: None, + artifact_id: None, + preview_build_id, + error_summary: None, + created_at: now, + started_at: None, + finished_at: None, + updated_at: now, + }); + let row = require_runtime_job(ctx, job_id.as_str())?; + Ok(runtime_job_snapshot_from_row(row)) +} + +fn get_web_project_runtime_job( + ctx: &ReducerContext, + input: WebProjectRuntimeJobGetInput, +) -> Result { + let job_id = normalize_required(&input.job_id, "web_project_runtime_job.job_id")?; + let owner_user_id = normalize_required( + &input.owner_user_id, + "web_project_runtime_job.owner_user_id", + )?; + let row = require_runtime_job(ctx, job_id.as_str())?; + if row.owner_user_id != owner_user_id { + return Err("Web Project runtime job 不存在".to_string()); + } + Ok(runtime_job_snapshot_from_row(row)) +} + +fn list_open_web_project_runtime_jobs( + ctx: &ReducerContext, + input: WebProjectRuntimeJobListOpenInput, +) -> Result, String> { + let project_id = normalize_required(&input.project_id, "web_project_runtime_job.project_id")?; + let owner_user_id = normalize_required( + &input.owner_user_id, + "web_project_runtime_job.owner_user_id", + )?; + require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?; + let project_key = project_id.to_string(); + let limit = input.limit.clamp(1, 100) as usize; + let mut jobs = ctx + .db + .web_project_runtime_job() + .by_web_project_runtime_job_project_id() + .filter(&project_key) + .filter(|row| { + row.owner_user_id == owner_user_id + && !is_terminal_runtime_job_status(row.status.as_str()) + }) + .collect::>(); + + jobs.sort_by(|left, right| { + right + .created_at + .to_micros_since_unix_epoch() + .cmp(&left.created_at.to_micros_since_unix_epoch()) + .then_with(|| right.job_id.cmp(&left.job_id)) + }); + + Ok(jobs + .into_iter() + .take(limit) + .map(runtime_job_snapshot_from_row) + .collect()) +} + +fn claim_web_project_runtime_jobs( + ctx: &ReducerContext, + input: WebProjectRuntimeJobClaimInput, +) -> Result, String> { + let worker_id = normalize_required(&input.worker_id, "web_project_runtime_job.worker_id")?; + if input.limit == 0 { + return Ok(Vec::new()); + } + let claim_time = ctx.timestamp; + let lease_duration_micros = runtime_job_duration_between_micros( + input.lease_expires_at_micros, + input.claimed_at_micros, + "web_project_runtime_job.lease_duration", + )?; + let lease_expires_at = runtime_job_timestamp_after_micros(claim_time, lease_duration_micros); + let mut candidates = Vec::new(); + + candidates.extend( + ctx.db + .web_project_runtime_job() + .by_web_project_runtime_job_status() + .filter(&WEB_PROJECT_RUNTIME_JOB_STATUS_QUEUED.to_string()) + .filter(|row| is_runtime_job_claimable(row, claim_time)), + ); + candidates.extend( + ctx.db + .web_project_runtime_job() + .by_web_project_runtime_job_status() + .filter(&WEB_PROJECT_RUNTIME_JOB_STATUS_RUNNING.to_string()) + .filter(|row| is_runtime_job_claimable(row, claim_time)), + ); + + candidates.sort_by(|left, right| { + left.created_at + .to_micros_since_unix_epoch() + .cmp(&right.created_at.to_micros_since_unix_epoch()) + .then_with(|| left.job_id.cmp(&right.job_id)) + }); + + let mut claimed = Vec::new(); + for mut row in candidates.into_iter().take(input.limit.min(64) as usize) { + let next_attempt = row.attempt.saturating_add(1); + let lease_token = build_runtime_job_lease_token( + row.job_id.as_str(), + worker_id.as_str(), + next_attempt, + claim_time, + ); + row.status = WEB_PROJECT_RUNTIME_JOB_STATUS_RUNNING.to_string(); + row.attempt = next_attempt; + row.worker_id = Some(worker_id.clone()); + row.lease_token = Some(lease_token); + row.lease_expires_at = Some(lease_expires_at); + row.cancel_requested_at = None; + if row.started_at.is_none() { + row.started_at = Some(claim_time); + } + row.updated_at = claim_time; + persist_runtime_job_row(ctx, row.clone()); + claimed.push(runtime_job_snapshot_from_row(row)); + } + + Ok(claimed) +} + +fn renew_web_project_runtime_job_lease( + ctx: &ReducerContext, + input: WebProjectRuntimeJobRenewLeaseInput, +) -> Result { + let mut row = + require_worker_owned_runtime_job(ctx, &input.job_id, &input.worker_id, &input.lease_token)?; + let renewed_at = ctx.timestamp; + let lease_duration_micros = runtime_job_duration_between_micros( + input.lease_expires_at_micros, + input.renewed_at_micros, + "web_project_runtime_job.lease_duration", + )?; + row.lease_expires_at = Some(runtime_job_timestamp_after_micros( + renewed_at, + lease_duration_micros, + )); + row.updated_at = renewed_at; + persist_runtime_job_row(ctx, row.clone()); + Ok(runtime_job_snapshot_from_row(row)) +} + +fn complete_web_project_runtime_job( + ctx: &ReducerContext, + input: WebProjectRuntimeJobCompleteInput, +) -> Result { + let mut row = + require_worker_owned_runtime_job(ctx, &input.job_id, &input.worker_id, &input.lease_token)?; + let completed_at = ctx.timestamp; + if row.cancel_requested_at.is_some() { + row = runtime_job_cancelled_row(row, completed_at); + persist_runtime_job_row(ctx, row.clone()); + return Ok(runtime_job_snapshot_from_row(row)); + } + let project = require_owned_project(ctx, row.project_id.as_str(), row.owner_user_id.as_str())?; + if project.active_snapshot_id != row.snapshot_id { + row = runtime_job_stale_row(row, completed_at, "job snapshot 已不是项目 active snapshot"); + persist_runtime_job_row(ctx, row.clone()); + return Ok(runtime_job_snapshot_from_row(row)); + } + let preview_build_id = normalize_optional(input.preview_build_id).or(row.preview_build_id); + if let Some(preview_build_id) = preview_build_id.as_deref() { + require_runtime_job_preview_build_link( + ctx, + preview_build_id, + row.project_id.as_str(), + row.snapshot_id.as_str(), + row.owner_user_id.as_str(), + )?; + } + row.status = WEB_PROJECT_RUNTIME_JOB_STATUS_SUCCEEDED.to_string(); + row.worker_id = None; + row.lease_token = None; + row.lease_expires_at = None; + row.artifact_id = normalize_optional(input.artifact_id).or(row.artifact_id); + row.preview_build_id = preview_build_id; + row.error_summary = None; + row.finished_at = Some(completed_at); + row.updated_at = completed_at; + persist_runtime_job_row(ctx, row.clone()); + Ok(runtime_job_snapshot_from_row(row)) +} + +fn complete_web_project_preview_build_runtime_job( + ctx: &ReducerContext, + input: WebProjectRuntimeJobCompletePreviewBuildInput, +) -> Result< + ( + WebProjectProjectSnapshot, + WebProjectPreviewBuildSnapshot, + WebProjectRuntimeJobSnapshot, + ), + String, +> { + let mut row = + require_worker_owned_runtime_job(ctx, &input.job_id, &input.worker_id, &input.lease_token)?; + let preview_build_id = normalize_required( + &input.preview_build_id, + "web_project_runtime_job.preview_build_id", + )?; + require_runtime_job_preview_build_link( + ctx, + preview_build_id.as_str(), + row.project_id.as_str(), + row.snapshot_id.as_str(), + row.owner_user_id.as_str(), + )?; + let project = require_owned_project(ctx, row.project_id.as_str(), row.owner_user_id.as_str())?; + let build = require_build(ctx, preview_build_id.as_str())?; + if is_terminal_build_status(build.status.as_str()) { + return Err("Web 工程预览构建已终态,不能重复写回 runtime job 结果".to_string()); + } + + let finished_at = Timestamp::from_micros_since_unix_epoch(input.finished_at_micros); + let completed_at = ctx.timestamp; + let started_at = input + .started_at_micros + .map(Timestamp::from_micros_since_unix_epoch) + .or(build.started_at); + let logs_json = serialize_logs(&input.logs)?; + + if row.cancel_requested_at.is_some() { + row = runtime_job_cancelled_row(row, completed_at); + row.preview_build_id = Some(preview_build_id.clone()); + persist_runtime_job_row(ctx, row.clone()); + persist_preview_build_row( + ctx, + WebProjectPreviewBuildRow { + status: "cancelled".to_string(), + logs_json, + artifact_id: None, + preview_token_id: None, + preview_url: None, + error_summary: Some("构建任务已取消".to_string()), + started_at, + finished_at: Some(finished_at), + updated_at: finished_at, + ..build + }, + ); + + let project = + require_owned_project(ctx, row.project_id.as_str(), row.owner_user_id.as_str())?; + let build = require_build(ctx, preview_build_id.as_str())?; + let job = require_runtime_job(ctx, row.job_id.as_str())?; + return Ok(( + project_record_from_row(project), + build_snapshot_from_row(build)?, + runtime_job_snapshot_from_row(job), + )); + } + + if project.active_snapshot_id != row.snapshot_id { + let stale_reason = "job snapshot 已不是项目 active snapshot"; + row = runtime_job_stale_row(row, completed_at, stale_reason); + row.preview_build_id = Some(preview_build_id.clone()); + persist_runtime_job_row(ctx, row.clone()); + persist_preview_build_row( + ctx, + WebProjectPreviewBuildRow { + status: "stale".to_string(), + logs_json, + artifact_id: None, + preview_token_id: None, + preview_url: None, + error_summary: Some(stale_reason.to_string()), + started_at, + finished_at: Some(finished_at), + updated_at: finished_at, + ..build + }, + ); + + let project = + require_owned_project(ctx, row.project_id.as_str(), row.owner_user_id.as_str())?; + let build = require_build(ctx, preview_build_id.as_str())?; + let job = require_runtime_job(ctx, row.job_id.as_str())?; + return Ok(( + project_record_from_row(project), + build_snapshot_from_row(build)?, + runtime_job_snapshot_from_row(job), + )); + } + + validate_build_status_transition(build.status.as_str(), "succeeded")?; + let artifact_id = + normalize_required(&input.artifact_id, "web_project_runtime_job.artifact_id")?; + let preview_token_id = normalize_required( + &input.preview_token_id, + "web_project_preview_build.preview_token_id", + )?; + let preview_url = + normalize_required(&input.preview_url, "web_project_preview_build.preview_url")?; + + row.status = WEB_PROJECT_RUNTIME_JOB_STATUS_SUCCEEDED.to_string(); + row.worker_id = None; + row.lease_token = None; + row.lease_expires_at = None; + row.artifact_id = Some(artifact_id.clone()); + row.preview_build_id = Some(preview_build_id.clone()); + row.error_summary = None; + row.finished_at = Some(completed_at); + row.updated_at = completed_at; + persist_runtime_job_row(ctx, row.clone()); + persist_preview_build_row( + ctx, + WebProjectPreviewBuildRow { + status: "succeeded".to_string(), + logs_json, + artifact_id: Some(artifact_id), + preview_token_id: Some(preview_token_id), + preview_url: Some(preview_url), + error_summary: None, + started_at, + finished_at: Some(finished_at), + updated_at: finished_at, + ..build + }, + ); + + ctx.db + .web_project() + .project_id() + .delete(&project.project_id); + ctx.db.web_project().insert(WebProject { + active_preview_build_id: Some(preview_build_id.clone()), + updated_at: finished_at, + ..project + }); + + let project = require_owned_project(ctx, row.project_id.as_str(), row.owner_user_id.as_str())?; + let build = require_build(ctx, preview_build_id.as_str())?; + let job = require_runtime_job(ctx, row.job_id.as_str())?; + Ok(( + project_record_from_row(project), + build_snapshot_from_row(build)?, + runtime_job_snapshot_from_row(job), + )) +} + +fn fail_web_project_runtime_job( + ctx: &ReducerContext, + input: WebProjectRuntimeJobFailInput, +) -> Result { + let mut row = + require_worker_owned_runtime_job(ctx, &input.job_id, &input.worker_id, &input.lease_token)?; + let failed_at = ctx.timestamp; + if row.cancel_requested_at.is_some() { + row = runtime_job_cancelled_row(row, failed_at); + persist_runtime_job_row(ctx, row.clone()); + return Ok(runtime_job_snapshot_from_row(row)); + } + row.status = WEB_PROJECT_RUNTIME_JOB_STATUS_FAILED.to_string(); + row.worker_id = None; + row.lease_token = None; + row.lease_expires_at = None; + row.error_summary = Some(normalize_runtime_job_error_summary(&input.error_summary)); + row.finished_at = Some(failed_at); + row.updated_at = failed_at; + persist_runtime_job_row(ctx, row.clone()); + Ok(runtime_job_snapshot_from_row(row)) +} + +fn cancel_web_project_runtime_job( + ctx: &ReducerContext, + input: WebProjectRuntimeJobCancelInput, +) -> Result { + let job_id = normalize_required(&input.job_id, "web_project_runtime_job.job_id")?; + let owner_user_id = normalize_required( + &input.owner_user_id, + "web_project_runtime_job.owner_user_id", + )?; + let mut row = require_runtime_job(ctx, job_id.as_str())?; + if row.owner_user_id != owner_user_id { + return Err("Web Project runtime job 不存在".to_string()); + } + if is_terminal_runtime_job_status(row.status.as_str()) { + return Ok(runtime_job_snapshot_from_row(row)); + } + let cancelled_at = Timestamp::from_micros_since_unix_epoch(input.cancelled_at_micros); + row.cancel_requested_at = Some(cancelled_at); + row.updated_at = cancelled_at; + if row.status == WEB_PROJECT_RUNTIME_JOB_STATUS_QUEUED { + row = runtime_job_cancelled_row(row, cancelled_at); + } + persist_runtime_job_row(ctx, row.clone()); + Ok(runtime_job_snapshot_from_row(row)) +} + +fn mark_web_project_runtime_job_stale( + ctx: &ReducerContext, + input: WebProjectRuntimeJobStaleInput, +) -> Result { + let job_id = normalize_required(&input.job_id, "web_project_runtime_job.job_id")?; + let owner_user_id = normalize_required( + &input.owner_user_id, + "web_project_runtime_job.owner_user_id", + )?; + let mut row = require_runtime_job(ctx, job_id.as_str())?; + if row.owner_user_id != owner_user_id { + return Err("Web Project runtime job 不存在".to_string()); + } + if is_terminal_runtime_job_status(row.status.as_str()) { + return Ok(runtime_job_snapshot_from_row(row)); + } + let stale_at = Timestamp::from_micros_since_unix_epoch(input.stale_at_micros); + row = runtime_job_stale_row(row, stale_at, &input.stale_reason); + persist_runtime_job_row(ctx, row.clone()); + Ok(runtime_job_snapshot_from_row(row)) +} + +fn expire_web_project_runtime_job( + ctx: &ReducerContext, + input: WebProjectRuntimeJobExpireInput, +) -> Result { + let job_id = normalize_required(&input.job_id, "web_project_runtime_job.job_id")?; + let owner_user_id = normalize_required( + &input.owner_user_id, + "web_project_runtime_job.owner_user_id", + )?; + let mut row = require_runtime_job(ctx, job_id.as_str())?; + if row.owner_user_id != owner_user_id { + return Err("Web Project runtime job 不存在".to_string()); + } + if is_terminal_runtime_job_status(row.status.as_str()) { + return Ok(runtime_job_snapshot_from_row(row)); + } + if row.status != WEB_PROJECT_RUNTIME_JOB_STATUS_RUNNING { + return Err("只有 running Web Project runtime job 可以标记 expired".to_string()); + } + if is_runtime_job_lease_active(&row, ctx.timestamp) { + return Err("Web Project runtime job lease 尚未过期".to_string()); + } + let expired_at = Timestamp::from_micros_since_unix_epoch(input.expired_at_micros); + row.status = WEB_PROJECT_RUNTIME_JOB_STATUS_EXPIRED.to_string(); + row.worker_id = None; + row.lease_token = None; + row.lease_expires_at = None; + row.finished_at = Some(expired_at); + row.updated_at = expired_at; + persist_runtime_job_row(ctx, row.clone()); + Ok(runtime_job_snapshot_from_row(row)) +} + +fn append_web_project_runtime_job_log( + ctx: &ReducerContext, + input: WebProjectRuntimeJobAppendLogInput, +) -> Result { + let log_id = normalize_required(&input.log_id, "web_project_runtime_job_log.log_id")?; + let job_id = normalize_required(&input.job_id, "web_project_runtime_job_log.job_id")?; + let owner_user_id = normalize_required( + &input.owner_user_id, + "web_project_runtime_job_log.owner_user_id", + )?; + let row = require_runtime_job(ctx, job_id.as_str())?; + if row.owner_user_id != owner_user_id { + return Err("Web Project runtime job 不存在".to_string()); + } + match (input.worker_id.as_deref(), input.lease_token.as_deref()) { + (Some(worker_id), Some(lease_token)) => { + require_worker_owned_runtime_job(ctx, job_id.as_str(), worker_id, lease_token)?; + } + (None, None) => {} + _ => { + return Err( + "web_project_runtime_job_log worker_id 与 lease_token 必须同时提供".to_string(), + ); + } + } + if ctx + .db + .web_project_runtime_job_log() + .log_id() + .find(&log_id) + .is_some() + { + return Err("Web Project runtime job 日志已存在".to_string()); + } + let job_key = job_id.to_string(); + if ctx + .db + .web_project_runtime_job_log() + .by_web_project_runtime_job_log_job_id() + .filter(&job_key) + .any(|log| log.sequence == input.sequence) + { + return Err("Web Project runtime job 日志 sequence 已存在".to_string()); + } + let log = WebProjectRuntimeJobLogRow { + log_id: log_id.clone(), + job_id, + project_id: row.project_id, + owner_user_id: row.owner_user_id, + sequence: input.sequence, + level: normalize_runtime_job_log_level(&input.level), + message: normalize_runtime_job_log_message(&input.message)?, + created_at: Timestamp::from_micros_since_unix_epoch(input.created_at_micros), + }; + ctx.db.web_project_runtime_job_log().insert(log); + let log = require_runtime_job_log(ctx, log_id.as_str())?; + Ok(runtime_job_log_snapshot_from_row(log)) +} + +fn list_web_project_runtime_job_logs( + ctx: &ReducerContext, + input: WebProjectRuntimeJobListLogsInput, +) -> Result, String> { + let job_id = normalize_required(&input.job_id, "web_project_runtime_job_log.job_id")?; + let owner_user_id = normalize_required( + &input.owner_user_id, + "web_project_runtime_job_log.owner_user_id", + )?; + let row = require_runtime_job(ctx, job_id.as_str())?; + if row.owner_user_id != owner_user_id { + return Err("Web Project runtime job 不存在".to_string()); + } + let after_sequence = input.after_sequence.unwrap_or(0); + let limit = input.limit.clamp(1, 200) as usize; + let job_key = job_id.to_string(); + let mut logs = ctx + .db + .web_project_runtime_job_log() + .by_web_project_runtime_job_log_job_id() + .filter(&job_key) + .filter(|log| log.owner_user_id == owner_user_id && log.sequence > after_sequence) + .collect::>(); + + logs.sort_by(|left, right| { + left.sequence + .cmp(&right.sequence) + .then_with(|| { + left.created_at + .to_micros_since_unix_epoch() + .cmp(&right.created_at.to_micros_since_unix_epoch()) + }) + .then_with(|| left.log_id.cmp(&right.log_id)) + }); + + Ok(logs + .into_iter() + .take(limit) + .map(runtime_job_log_snapshot_from_row) + .collect()) +} + fn authorize_web_project_service_identity_inner( ctx: &mut ProcedureContext, input: WebProjectServiceIdentityAuthorizeInput, @@ -814,6 +1876,26 @@ fn mark_open_builds_stale(ctx: &ReducerContext, project_id: &str, now: Timestamp } } +fn mark_open_runtime_jobs_stale( + ctx: &ReducerContext, + project_id: &str, + now: Timestamp, + reason: &str, +) { + let project_key = project_id.to_string(); + let jobs = ctx + .db + .web_project_runtime_job() + .by_web_project_runtime_job_project_id() + .filter(&project_key) + .collect::>(); + for job in jobs { + if !is_terminal_runtime_job_status(job.status.as_str()) { + persist_runtime_job_row(ctx, runtime_job_stale_row(job, now, reason)); + } + } +} + fn require_owned_project( ctx: &ReducerContext, project_id: &str, @@ -876,6 +1958,75 @@ fn require_build(ctx: &ReducerContext, job_id: &str) -> Result Result { + let job_key = job_id.to_string(); + ctx.db + .web_project_runtime_job() + .job_id() + .find(&job_key) + .ok_or_else(|| "Web Project runtime job 不存在".to_string()) +} + +fn require_runtime_job_log( + ctx: &ReducerContext, + log_id: &str, +) -> Result { + let log_key = log_id.to_string(); + ctx.db + .web_project_runtime_job_log() + .log_id() + .find(&log_key) + .ok_or_else(|| "Web Project runtime job 日志不存在".to_string()) +} + +fn require_worker_owned_runtime_job( + ctx: &ReducerContext, + job_id: &str, + worker_id: &str, + lease_token: &str, +) -> Result { + let job_id = normalize_required(job_id, "web_project_runtime_job.job_id")?; + let worker_id = normalize_required(worker_id, "web_project_runtime_job.worker_id")?; + let lease_token = normalize_required(lease_token, "web_project_runtime_job.lease_token")?; + let row = require_runtime_job(ctx, job_id.as_str())?; + if row.status != WEB_PROJECT_RUNTIME_JOB_STATUS_RUNNING { + return Err("Web Project runtime job 当前不是 running 状态".to_string()); + } + if row.worker_id.as_deref() != Some(worker_id.as_str()) { + return Err("Web Project runtime job worker lease 不匹配".to_string()); + } + if row.lease_token.as_deref() != Some(lease_token.as_str()) { + return Err("Web Project runtime job lease token 不匹配".to_string()); + } + if !is_runtime_job_lease_active(&row, ctx.timestamp) { + return Err("Web Project runtime job lease 已过期".to_string()); + } + Ok(row) +} + +fn require_runtime_job_preview_build_link( + ctx: &ReducerContext, + preview_build_id: &str, + project_id: &str, + snapshot_id: &str, + owner_user_id: &str, +) -> Result<(), String> { + let build = require_build(ctx, preview_build_id)?; + if build.project_id != project_id { + return Err("Web Project runtime job 关联的 preview build 项目不匹配".to_string()); + } + if build.snapshot_id != snapshot_id { + return Err("Web Project runtime job 关联的 preview build 快照不匹配".to_string()); + } + if build.owner_user_id != owner_user_id { + return Err("Web Project runtime job 关联的 preview build 归属不匹配".to_string()); + } + Ok(()) +} + fn project_record_from_row(row: WebProject) -> WebProjectProjectSnapshot { WebProjectProjectSnapshot { project_id: row.project_id, @@ -928,6 +2079,53 @@ fn build_snapshot_from_row( }) } +fn runtime_job_snapshot_from_row(row: WebProjectRuntimeJobRow) -> WebProjectRuntimeJobSnapshot { + WebProjectRuntimeJobSnapshot { + job_id: row.job_id, + project_id: row.project_id, + snapshot_id: row.snapshot_id, + owner_user_id: row.owner_user_id, + job_kind: row.job_kind, + status: row.status, + attempt: row.attempt, + worker_id: row.worker_id, + lease_token: row.lease_token, + lease_expires_at_micros: row + .lease_expires_at + .map(|value| value.to_micros_since_unix_epoch()), + cancel_requested_at_micros: row + .cancel_requested_at + .map(|value| value.to_micros_since_unix_epoch()), + stale_reason: row.stale_reason, + artifact_id: row.artifact_id, + preview_build_id: row.preview_build_id, + error_summary: row.error_summary, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + started_at_micros: row + .started_at + .map(|value| value.to_micros_since_unix_epoch()), + finished_at_micros: row + .finished_at + .map(|value| value.to_micros_since_unix_epoch()), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn runtime_job_log_snapshot_from_row( + row: WebProjectRuntimeJobLogRow, +) -> WebProjectRuntimeJobLogSnapshot { + WebProjectRuntimeJobLogSnapshot { + log_id: row.log_id, + job_id: row.job_id, + project_id: row.project_id, + owner_user_id: row.owner_user_id, + sequence: row.sequence, + level: row.level, + message: row.message, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + } +} + fn serialize_files(files: &[WebProjectFileSnapshot]) -> Result { if files.is_empty() { return Err("Web 工程至少需要一个源码文件".to_string()); @@ -1013,9 +2211,7 @@ fn validate_build_status_transition(current: &str, next: &str) -> Result<(), Str ("queued", "running" | "failed" | "cancelled" | "expired" | "stale") => Ok(()), ("running", "succeeded" | "failed" | "cancelled" | "expired" | "stale") => Ok(()), _ if current == next => Ok(()), - _ => Err(format!( - "Web 工程构建状态不能从 {current} 更新为 {next}" - )), + _ => Err(format!("Web 工程构建状态不能从 {current} 更新为 {next}")), } } @@ -1026,6 +2222,168 @@ fn is_terminal_build_status(status: &str) -> bool { ) } +fn normalize_runtime_job_kind(value: &str) -> Result { + let job_kind = value.trim(); + match job_kind { + WEB_PROJECT_RUNTIME_JOB_KIND_PREVIEW_BUILD => Ok(job_kind.to_string()), + _ => Err("Web Project runtime job 类型不合法".to_string()), + } +} + +fn is_terminal_runtime_job_status(status: &str) -> bool { + matches!( + status, + WEB_PROJECT_RUNTIME_JOB_STATUS_SUCCEEDED + | WEB_PROJECT_RUNTIME_JOB_STATUS_FAILED + | WEB_PROJECT_RUNTIME_JOB_STATUS_CANCELLED + | WEB_PROJECT_RUNTIME_JOB_STATUS_EXPIRED + | WEB_PROJECT_RUNTIME_JOB_STATUS_STALE + ) +} + +fn is_runtime_job_claimable(row: &WebProjectRuntimeJobRow, now: Timestamp) -> bool { + match row.status.as_str() { + WEB_PROJECT_RUNTIME_JOB_STATUS_QUEUED => row.cancel_requested_at.is_none(), + WEB_PROJECT_RUNTIME_JOB_STATUS_RUNNING => { + row.cancel_requested_at.is_none() + && row + .lease_expires_at + .map(|lease_expires_at| lease_expires_at <= now) + .unwrap_or(true) + } + _ => false, + } +} + +fn is_runtime_job_lease_active(row: &WebProjectRuntimeJobRow, now: Timestamp) -> bool { + row.lease_expires_at + .map(|lease_expires_at| lease_expires_at > now) + .unwrap_or(false) +} + +fn persist_runtime_job_row(ctx: &ReducerContext, row: WebProjectRuntimeJobRow) { + ctx.db + .web_project_runtime_job() + .job_id() + .delete(&row.job_id); + ctx.db.web_project_runtime_job().insert(row); +} + +fn persist_preview_build_row(ctx: &ReducerContext, row: WebProjectPreviewBuildRow) { + ctx.db + .web_project_preview_build() + .job_id() + .delete(&row.job_id); + ctx.db.web_project_preview_build().insert(row); +} + +fn runtime_job_stale_row( + mut row: WebProjectRuntimeJobRow, + stale_at: Timestamp, + reason: &str, +) -> WebProjectRuntimeJobRow { + row.status = WEB_PROJECT_RUNTIME_JOB_STATUS_STALE.to_string(); + row.worker_id = None; + row.lease_token = None; + row.lease_expires_at = None; + row.stale_reason = Some(normalize_runtime_job_stale_reason(reason)); + row.finished_at = Some(stale_at); + row.updated_at = stale_at; + row +} + +fn runtime_job_cancelled_row( + mut row: WebProjectRuntimeJobRow, + cancelled_at: Timestamp, +) -> WebProjectRuntimeJobRow { + row.status = WEB_PROJECT_RUNTIME_JOB_STATUS_CANCELLED.to_string(); + row.worker_id = None; + row.lease_token = None; + row.lease_expires_at = None; + row.finished_at = Some(cancelled_at); + row.updated_at = cancelled_at; + row +} + +fn build_runtime_job_lease_token( + job_id: &str, + worker_id: &str, + attempt: u32, + claimed_at: Timestamp, +) -> String { + format!( + "{}:{}:{}:{}", + job_id.trim(), + worker_id.trim(), + attempt, + claimed_at.to_micros_since_unix_epoch() + ) +} + +fn runtime_job_duration_between_micros( + later: i64, + earlier: i64, + field: &str, +) -> Result { + let duration = later.saturating_sub(earlier); + if duration <= 0 { + return Err(format!("{field} 必须大于 0")); + } + Ok(duration) +} + +fn runtime_job_timestamp_after_micros(timestamp: Timestamp, duration_micros: i64) -> Timestamp { + Timestamp::from_micros_since_unix_epoch( + timestamp + .to_micros_since_unix_epoch() + .saturating_add(duration_micros.max(0)), + ) +} + +fn normalize_runtime_job_error_summary(value: &str) -> String { + let summary = value.trim(); + if summary.is_empty() { + return "Web Project runtime job 执行失败".to_string(); + } + summary + .chars() + .take(WEB_PROJECT_RUNTIME_JOB_MAX_ERROR_CHARS) + .collect() +} + +fn normalize_runtime_job_stale_reason(value: &str) -> String { + let reason = value.trim(); + if reason.is_empty() { + return "Web Project runtime job 已失效".to_string(); + } + reason + .chars() + .take(WEB_PROJECT_RUNTIME_JOB_MAX_STALE_REASON_CHARS) + .collect() +} + +fn normalize_runtime_job_log_level(value: &str) -> String { + let level = value.trim(); + if level.is_empty() { + return "info".to_string(); + } + level + .chars() + .take(WEB_PROJECT_RUNTIME_JOB_LOG_MAX_LEVEL_CHARS) + .collect() +} + +fn normalize_runtime_job_log_message(value: &str) -> Result { + let message = value.trim(); + if message.is_empty() { + return Err("web_project_runtime_job_log.message 不能为空".to_string()); + } + Ok(message + .chars() + .take(WEB_PROJECT_RUNTIME_JOB_LOG_MAX_MESSAGE_CHARS) + .collect()) +} + fn parse_web_project_service_identity(input: &str) -> Result { let identity_hex = input.trim(); if identity_hex.len() != 64 || !identity_hex.chars().all(|value| value.is_ascii_hexdigit()) { @@ -1150,3 +2508,175 @@ fn web_project_preview_build_error(message: String) -> WebProjectPreviewBuildPro error_message: Some(message), } } + +fn web_project_runtime_job_ok( + job: Option, +) -> WebProjectRuntimeJobProcedureResult { + WebProjectRuntimeJobProcedureResult { + ok: true, + job, + jobs: Vec::new(), + log: None, + logs: Vec::new(), + error_message: None, + } +} + +fn web_project_runtime_job_log_ok( + log: Option, +) -> WebProjectRuntimeJobProcedureResult { + WebProjectRuntimeJobProcedureResult { + ok: true, + job: None, + jobs: Vec::new(), + log, + logs: Vec::new(), + error_message: None, + } +} + +fn web_project_runtime_job_error(message: String) -> WebProjectRuntimeJobProcedureResult { + WebProjectRuntimeJobProcedureResult { + ok: false, + job: None, + jobs: Vec::new(), + log: None, + logs: Vec::new(), + error_message: Some(message), + } +} + +fn web_project_runtime_job_preview_build_ok( + project: Option, + build: Option, + job: Option, +) -> WebProjectRuntimeJobPreviewBuildProcedureResult { + WebProjectRuntimeJobPreviewBuildProcedureResult { + ok: true, + project, + build, + job, + error_message: None, + } +} + +fn web_project_runtime_job_preview_build_error( + message: String, +) -> WebProjectRuntimeJobPreviewBuildProcedureResult { + WebProjectRuntimeJobPreviewBuildProcedureResult { + ok: false, + project: None, + build: None, + job: None, + error_message: Some(message), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_job_terminal_statuses_never_claimable() { + for status in [ + WEB_PROJECT_RUNTIME_JOB_STATUS_SUCCEEDED, + WEB_PROJECT_RUNTIME_JOB_STATUS_FAILED, + WEB_PROJECT_RUNTIME_JOB_STATUS_CANCELLED, + WEB_PROJECT_RUNTIME_JOB_STATUS_EXPIRED, + WEB_PROJECT_RUNTIME_JOB_STATUS_STALE, + ] { + let row = runtime_job_fixture(status); + + assert!(!is_runtime_job_claimable(&row, micros(10_000))); + } + } + + #[test] + fn running_runtime_job_is_claimable_after_lease_expires() { + let mut row = runtime_job_fixture(WEB_PROJECT_RUNTIME_JOB_STATUS_RUNNING); + row.lease_expires_at = Some(micros(2_000)); + + assert!(!is_runtime_job_claimable(&row, micros(1_999))); + assert!(is_runtime_job_claimable(&row, micros(2_000))); + } + + #[test] + fn runtime_job_with_cancel_request_is_not_claimable() { + let mut queued = runtime_job_fixture(WEB_PROJECT_RUNTIME_JOB_STATUS_QUEUED); + queued.cancel_requested_at = Some(micros(1_000)); + let mut running = runtime_job_fixture(WEB_PROJECT_RUNTIME_JOB_STATUS_RUNNING); + running.lease_expires_at = Some(micros(1_000)); + running.cancel_requested_at = Some(micros(500)); + + assert!(!is_runtime_job_claimable(&queued, micros(2_000))); + assert!(!is_runtime_job_claimable(&running, micros(2_000))); + } + + #[test] + fn runtime_job_lease_token_changes_with_attempt() { + let first = build_runtime_job_lease_token("job-1", "worker-a", 1, micros(1_000)); + let second = build_runtime_job_lease_token("job-1", "worker-a", 2, micros(2_000)); + + assert_ne!(first, second); + } + + #[test] + fn runtime_job_stale_row_clears_worker_lease() { + let mut row = runtime_job_fixture(WEB_PROJECT_RUNTIME_JOB_STATUS_RUNNING); + row.worker_id = Some("worker-a".to_string()); + row.lease_token = Some("lease-a".to_string()); + row.lease_expires_at = Some(micros(2_000)); + + let stale = runtime_job_stale_row(row, micros(3_000), "测试 stale"); + + assert_eq!(stale.status, WEB_PROJECT_RUNTIME_JOB_STATUS_STALE); + assert!(stale.worker_id.is_none()); + assert!(stale.lease_token.is_none()); + assert!(stale.lease_expires_at.is_none()); + assert_eq!(stale.stale_reason.as_deref(), Some("测试 stale")); + assert_eq!( + stale + .finished_at + .map(|value| value.to_micros_since_unix_epoch()), + Some(3_000), + ); + } + + #[test] + fn runtime_job_result_failure_is_structured() { + let result = web_project_runtime_job_error("失败".to_string()); + + assert!(!result.ok); + assert_eq!(result.error_message.as_deref(), Some("失败")); + assert!(result.jobs.is_empty()); + assert!(result.logs.is_empty()); + } + + fn runtime_job_fixture(status: &str) -> WebProjectRuntimeJobRow { + WebProjectRuntimeJobRow { + job_id: "runtime-job-1".to_string(), + project_id: "web-project-1".to_string(), + snapshot_id: "web-snapshot-1".to_string(), + owner_user_id: "user-1".to_string(), + job_kind: WEB_PROJECT_RUNTIME_JOB_KIND_PREVIEW_BUILD.to_string(), + status: status.to_string(), + attempt: 0, + worker_id: None, + lease_token: None, + lease_expires_at: None, + cancel_requested_at: None, + stale_reason: None, + artifact_id: None, + preview_build_id: None, + error_summary: None, + created_at: micros(1_000), + started_at: None, + finished_at: None, + updated_at: micros(1_000), + } + } + + fn micros(value: i64) -> Timestamp { + Timestamp::from_micros_since_unix_epoch(value) + } +} diff --git a/src/components/editor/agent/WebProjectAgentEditorPage.test.tsx b/src/components/editor/agent/WebProjectAgentEditorPage.test.tsx index 6d283651..9da49bdc 100644 --- a/src/components/editor/agent/WebProjectAgentEditorPage.test.tsx +++ b/src/components/editor/agent/WebProjectAgentEditorPage.test.tsx @@ -14,6 +14,7 @@ import type { WebProject, WebProjectFile, WebProjectPreviewBuild, + WebProjectRuntimeJob, WebProjectSnapshot, } from '../../../../packages/shared/src/contracts/webProject'; import { WebProjectAgentEditorPage } from './WebProjectAgentEditorPage'; @@ -25,12 +26,26 @@ const loadWebProjectPreviewBuildMock = vi.hoisted(() => vi.fn()); const saveWebProjectFilesMock = vi.hoisted(() => vi.fn()); const submitMockAgentTurnMock = vi.hoisted(() => vi.fn()); const createWebProjectPreviewBuildMock = vi.hoisted(() => vi.fn()); +const listOpenWebProjectRuntimeJobsMock = vi.hoisted(() => vi.fn()); +const listWebProjectRuntimeJobLogsMock = vi.hoisted(() => vi.fn()); +const cancelWebProjectRuntimeJobMock = vi.hoisted(() => vi.fn()); const subscribeWebProjectPreviewBuildEventsMock = vi.hoisted(() => vi.fn()); vi.mock('../../../services/web-project/webProjectClient', () => ({ + cancelWebProjectRuntimeJob: cancelWebProjectRuntimeJobMock, createWebProjectWithSnapshot: createWebProjectWithSnapshotMock, + createWebProjectPreviewBuildWithRuntimeJob: async ( + ...args: unknown[] + ) => { + const value = await createWebProjectPreviewBuildMock(...args); + return value && typeof value === 'object' && 'build' in value + ? value + : { build: value, runtimeJob: null }; + }, loadWebProject: loadWebProjectMock, loadActiveWebProjectSnapshot: loadActiveWebProjectSnapshotMock, + listOpenWebProjectRuntimeJobs: listOpenWebProjectRuntimeJobsMock, + listWebProjectRuntimeJobLogs: listWebProjectRuntimeJobLogsMock, loadWebProjectPreviewBuild: loadWebProjectPreviewBuildMock, saveWebProjectFiles: saveWebProjectFilesMock, submitMockAgentTurn: submitMockAgentTurnMock, @@ -109,6 +124,32 @@ function createBuild( }; } +function createRuntimeJob( + input: Partial = {}, +): WebProjectRuntimeJob { + return { + jobId: 'web-runtime-job-1', + projectId: 'web-project-1', + snapshotId: 'web-snapshot-1', + ownerUserId: 'user-1', + jobKind: 'preview_build', + status: 'queued', + attempt: 0, + workerId: null, + leaseExpiresAt: null, + cancelRequestedAt: null, + staleReason: null, + artifactId: null, + previewBuildId: 'web-build-1', + errorSummary: null, + createdAt: '2026-06-15T00:00:00.000Z', + startedAt: null, + finishedAt: null, + updatedAt: '2026-06-15T00:00:00.000Z', + ...input, + }; +} + describe('WebProjectAgentEditorPage', () => { beforeEach(() => { vi.resetAllMocks(); @@ -126,6 +167,15 @@ describe('WebProjectAgentEditorPage', () => { }); saveWebProjectFilesMock.mockResolvedValue(createSnapshot()); createWebProjectPreviewBuildMock.mockResolvedValue(createBuild()); + listOpenWebProjectRuntimeJobsMock.mockResolvedValue([]); + listWebProjectRuntimeJobLogsMock.mockResolvedValue({ + logs: [], + nextAfterSequence: null, + hasMore: false, + }); + cancelWebProjectRuntimeJobMock.mockResolvedValue( + createRuntimeJob({ status: 'cancelled' }), + ); subscribeWebProjectPreviewBuildEventsMock.mockResolvedValue(undefined); }); @@ -290,6 +340,243 @@ describe('WebProjectAgentEditorPage', () => { }); }); + it('keeps a queued preview build pending without creating a fake iframe', async () => { + const user = userEvent.setup(); + const queuedBuild = createBuild({ + jobId: 'web-build-queued', + status: 'queued', + previewUrl: null, + }); + createWebProjectPreviewBuildMock.mockResolvedValue(queuedBuild); + subscribeWebProjectPreviewBuildEventsMock.mockResolvedValue(new Promise(() => {})); + + render(); + + await screen.findByText('未命名 Web 工程'); + await user.click(screen.getByRole('button', { name: /构建/u })); + + await waitFor(() => { + expect(createWebProjectPreviewBuildMock).toHaveBeenCalledWith( + 'web-project-1', + { snapshotId: 'web-snapshot-1' }, + ); + }); + expect(subscribeWebProjectPreviewBuildEventsMock).toHaveBeenCalledWith( + 'web-build-queued', + expect.objectContaining({ onEvent: expect.any(Function) }), + ); + expect( + window.localStorage.getItem( + 'genarrative.web-project.pending-build.web-project-1', + ), + ).toBe('web-build-queued'); + expect(screen.getAllByText('排队中').length).toBeGreaterThan(0); + expect(screen.queryByTitle('Web 工程预览')).toBeNull(); + }); + + it('cancels the current queued runtime job through the runtime API', async () => { + const user = userEvent.setup(); + const queuedBuild = createBuild({ + jobId: 'web-build-queued', + status: 'queued', + previewUrl: null, + }); + const queuedRuntimeJob = createRuntimeJob({ + jobId: 'web-runtime-job-queued', + previewBuildId: 'web-build-queued', + status: 'queued', + }); + createWebProjectPreviewBuildMock.mockResolvedValue({ + build: queuedBuild, + runtimeJob: queuedRuntimeJob, + }); + cancelWebProjectRuntimeJobMock.mockResolvedValue({ + ...queuedRuntimeJob, + status: 'cancelled', + cancelRequestedAt: '2026-06-15T00:00:01.000Z', + finishedAt: '2026-06-15T00:00:01.000Z', + }); + loadWebProjectPreviewBuildMock.mockResolvedValue( + createBuild({ + jobId: 'web-build-queued', + status: 'cancelled', + logs: ['构建任务已取消'], + }), + ); + + render(); + + await screen.findByText('未命名 Web 工程'); + await user.click(screen.getByRole('button', { name: /构建/u })); + await user.click(await screen.findByRole('button', { name: /取消/u })); + + await waitFor(() => { + expect(cancelWebProjectRuntimeJobMock).toHaveBeenCalledWith( + 'web-runtime-job-queued', + ); + }); + expect(loadWebProjectPreviewBuildMock).toHaveBeenCalledWith( + 'web-build-queued', + ); + expect(screen.getAllByText('已取消').length).toBeGreaterThan(0); + }); + + it('restores open runtime job logs after refreshing an existing project', async () => { + window.history.replaceState(null, '', '/editor/agent?projectid=web-project-1'); + const runningRuntimeJob = createRuntimeJob({ + jobId: 'web-runtime-job-running', + previewBuildId: 'web-build-running', + status: 'running', + }); + loadWebProjectMock.mockResolvedValue(createProject()); + loadActiveWebProjectSnapshotMock.mockResolvedValue(createSnapshot()); + listOpenWebProjectRuntimeJobsMock.mockResolvedValue([runningRuntimeJob]); + listWebProjectRuntimeJobLogsMock.mockResolvedValue({ + logs: [ + { + logId: 'runtime-log-1', + jobId: 'web-runtime-job-running', + projectId: 'web-project-1', + ownerUserId: 'user-1', + sequence: 1, + level: 'info', + message: 'runtime worker: hydrate snapshot', + createdAt: '2026-06-17T00:00:00.000Z', + }, + { + logId: 'runtime-log-2', + jobId: 'web-runtime-job-running', + projectId: 'web-project-1', + ownerUserId: 'user-1', + sequence: 2, + level: 'info', + message: 'runtime worker: build started', + createdAt: '2026-06-17T00:00:01.000Z', + }, + ], + nextAfterSequence: 2, + hasMore: false, + }); + loadWebProjectPreviewBuildMock.mockResolvedValue( + createBuild({ + jobId: 'web-build-running', + status: 'running', + logs: ['构建任务已进入队列'], + }), + ); + subscribeWebProjectPreviewBuildEventsMock.mockResolvedValue(new Promise(() => {})); + + render(); + + expect(await screen.findByText('runtime worker: hydrate snapshot')).toBeTruthy(); + expect(screen.getByText('runtime worker: build started')).toBeTruthy(); + expect(listWebProjectRuntimeJobLogsMock).toHaveBeenCalledWith( + 'web-runtime-job-running', + { afterSequence: 0, limit: 100 }, + ); + expect(subscribeWebProjectPreviewBuildEventsMock).toHaveBeenCalledWith( + 'web-build-running', + expect.objectContaining({ onEvent: expect.any(Function) }), + ); + }); + + it('restores succeeded active preview build after refreshing an existing project', async () => { + window.history.replaceState(null, '', '/editor/agent?projectid=web-project-1'); + loadWebProjectMock.mockResolvedValue( + createProject({ activePreviewBuildId: 'web-build-active' }), + ); + loadActiveWebProjectSnapshotMock.mockResolvedValue(createSnapshot()); + loadWebProjectPreviewBuildMock.mockResolvedValue( + createBuild({ + jobId: 'web-build-active', + status: 'succeeded', + logs: ['构建任务已进入队列', '构建完成'], + previewUrl: 'http://127.0.0.1:3999/p/active-token/', + }), + ); + + render(); + + await waitFor(() => { + expect(loadWebProjectPreviewBuildMock).toHaveBeenCalledWith( + 'web-build-active', + ); + }); + expect(screen.getAllByText('已完成').length).toBeGreaterThan(0); + expect(screen.queryByText('排队中')).toBeNull(); + expect(screen.getByText('构建完成')).toBeTruthy(); + expect(screen.getByTitle('Web 工程预览').getAttribute('src')).toBe( + 'http://127.0.0.1:3999/p/active-token/', + ); + expect(subscribeWebProjectPreviewBuildEventsMock).not.toHaveBeenCalled(); + }); + + it('backfills runtime logs and reconnects when build SSE is interrupted', async () => { + const user = userEvent.setup(); + const queuedRuntimeJob = createRuntimeJob({ + jobId: 'web-runtime-job-reconnect', + previewBuildId: 'web-build-reconnect', + status: 'running', + }); + createWebProjectPreviewBuildMock.mockResolvedValue({ + build: createBuild({ + jobId: 'web-build-reconnect', + status: 'running', + logs: ['构建任务已进入队列'], + }), + runtimeJob: queuedRuntimeJob, + }); + listWebProjectRuntimeJobLogsMock + .mockResolvedValueOnce({ + logs: [], + nextAfterSequence: null, + hasMore: false, + }) + .mockResolvedValueOnce({ + logs: [ + { + logId: 'runtime-log-3', + jobId: 'web-runtime-job-reconnect', + projectId: 'web-project-1', + ownerUserId: 'user-1', + sequence: 3, + level: 'info', + message: 'runtime worker: restored after SSE drop', + createdAt: '2026-06-17T00:00:03.000Z', + }, + ], + nextAfterSequence: 3, + hasMore: false, + }); + loadWebProjectPreviewBuildMock.mockResolvedValue( + createBuild({ + jobId: 'web-build-reconnect', + status: 'running', + logs: ['构建任务已进入队列'], + }), + ); + subscribeWebProjectPreviewBuildEventsMock + .mockRejectedValueOnce(new Error('stream closed')) + .mockResolvedValue(new Promise(() => {})); + + render(); + + await screen.findByText('未命名 Web 工程'); + await user.click(screen.getByRole('button', { name: /构建/u })); + + await waitFor(() => { + expect(screen.getByText('runtime worker: restored after SSE drop')).toBeTruthy(); + }); + + await waitFor(() => { + expect(subscribeWebProjectPreviewBuildEventsMock).toHaveBeenCalledTimes(2); + }); + expect(listWebProjectRuntimeJobLogsMock).toHaveBeenLastCalledWith( + 'web-runtime-job-reconnect', + { afterSequence: 0, limit: 100 }, + ); + }); + it('keeps previous preview iframe when a later build fails', async () => { const user = userEvent.setup(); createWebProjectWithSnapshotMock.mockResolvedValueOnce({ @@ -344,6 +631,58 @@ describe('WebProjectAgentEditorPage', () => { ); }); + it('keeps previous preview iframe when a later build becomes stale', async () => { + const user = userEvent.setup(); + createWebProjectWithSnapshotMock.mockResolvedValueOnce({ + project: createProject({ activePreviewBuildId: 'web-build-active' }), + snapshot: createSnapshot(), + }); + loadWebProjectPreviewBuildMock.mockResolvedValueOnce( + createBuild({ + jobId: 'web-build-active', + status: 'succeeded', + previewUrl: 'http://127.0.0.1:3999/p/old-token/', + }), + ); + createWebProjectPreviewBuildMock.mockResolvedValue( + createBuild({ + jobId: 'web-build-stale', + }), + ); + subscribeWebProjectPreviewBuildEventsMock.mockImplementation( + async (_jobId: string, options: { onEvent: (event: unknown) => void }) => { + options.onEvent({ + jobId: 'web-build-stale', + status: 'stale', + message: '构建已过时', + build: createBuild({ + jobId: 'web-build-stale', + status: 'stale', + previewUrl: null, + errorSummary: 'job snapshot 已不是项目 active snapshot', + }), + }); + }, + ); + + render(); + + await waitFor(() => { + expect(screen.getByTitle('Web 工程预览').getAttribute('src')).toBe( + 'http://127.0.0.1:3999/p/old-token/', + ); + }); + + await user.click(screen.getByRole('button', { name: /构建/u })); + + await waitFor(() => { + expect(screen.getAllByText('已过时').length).toBeGreaterThan(0); + }); + expect(screen.getByTitle('Web 工程预览').getAttribute('src')).toBe( + 'http://127.0.0.1:3999/p/old-token/', + ); + }); + it('uses mobile tabs for the compact layout', async () => { window.matchMedia = vi.fn().mockImplementation((query: string) => ({ matches: !query.includes('1024px'), diff --git a/src/components/editor/agent/WebProjectAgentEditorPage.tsx b/src/components/editor/agent/WebProjectAgentEditorPage.tsx index 10f51483..adb7f798 100644 --- a/src/components/editor/agent/WebProjectAgentEditorPage.tsx +++ b/src/components/editor/agent/WebProjectAgentEditorPage.tsx @@ -8,6 +8,7 @@ import { Save, Send, TerminalSquare, + XCircle, } from 'lucide-react'; import { type FormEvent, @@ -22,6 +23,7 @@ import type { WebProject, WebProjectFile, WebProjectPreviewBuild, + WebProjectRuntimeJob, WebProjectSnapshot, } from '../../../../packages/shared/src/contracts/webProject'; import { PlatformActionButton } from '../../common/PlatformActionButton'; @@ -29,8 +31,11 @@ import { PlatformSegmentedTabs } from '../../common/PlatformSegmentedTabs'; import { PlatformStatusMessage } from '../../common/PlatformStatusMessage'; import { PlatformSubpanel } from '../../common/PlatformSubpanel'; import { - createWebProjectPreviewBuild, + cancelWebProjectRuntimeJob, + createWebProjectPreviewBuildWithRuntimeJob, createWebProjectWithSnapshot, + listOpenWebProjectRuntimeJobs, + listWebProjectRuntimeJobLogs, loadActiveWebProjectSnapshot, loadWebProject, loadWebProjectPreviewBuild, @@ -40,11 +45,13 @@ import { import { subscribeWebProjectPreviewBuildEvents } from '../../../services/web-project/webProjectSse'; import { buildLogEntriesFromPreviewBuild, + buildLogEntriesFromRuntimeJobLogs, createBuildLogEntry, createUpdateFilePatch, findWebProjectFile, hasFileContentChanged, isTerminalWebProjectBuild, + mergeWebProjectAgentLogEntries, mergeWebProjectBuildEvent, resolveInitialSelectedFilePath, sortWebProjectFiles, @@ -56,6 +63,9 @@ type WebProjectAgentMobileTab = 'files' | 'code' | 'preview' | 'logs'; const WEB_PROJECT_QUERY_KEYS = ['projectid', 'projectId'] as const; const WEB_PROJECT_PENDING_BUILD_STORAGE_PREFIX = 'genarrative.web-project.pending-build.'; +const WEB_PROJECT_RUNTIME_LOG_PAGE_LIMIT = 100; +const WEB_PROJECT_RUNTIME_LOG_MAX_BACKFILL_PAGES = 5; +const WEB_PROJECT_SSE_RECONNECT_DELAY_MS = 300; const MOBILE_TABS = [ { id: 'files', label: '文件' }, @@ -143,6 +153,10 @@ function buildStatusLabel(build: WebProjectPreviewBuild | null) { } } +function isOpenRuntimeJob(job: WebProjectRuntimeJob | null) { + return job?.status === 'queued' || job?.status === 'running'; +} + function useAgentDesktopLayout() { const [isDesktop, setIsDesktop] = useState(() => typeof window !== 'undefined' && typeof window.matchMedia === 'function' @@ -174,16 +188,23 @@ function useWebProjectAgentController() { const [activePreviewUrl, setActivePreviewUrl] = useState(null); const [currentBuild, setCurrentBuild] = useState(null); + const [currentRuntimeJob, setCurrentRuntimeJob] = + useState(null); const [logs, setLogs] = useState([]); const [errorMessage, setErrorMessage] = useState(null); const [isBootstrapping, setIsBootstrapping] = useState(true); const [isSaving, setIsSaving] = useState(false); const [isMockAgentBusy, setIsMockAgentBusy] = useState(false); const [isCreatingBuild, setIsCreatingBuild] = useState(false); + const [isCancellingBuild, setIsCancellingBuild] = useState(false); const [mobileTab, setMobileTab] = useState('code'); const buildAbortControllerRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const runtimeJobIdByPreviewBuildRef = useRef>({}); + const runtimeLogCursorRef = useRef>({}); const selectedFilePathRef = useRef(null); + const subscribeToBuildRef = useRef<(jobId: string) => void>(() => undefined); const files = useMemo( () => sortWebProjectFiles(snapshot?.files ?? []), @@ -196,8 +217,25 @@ function useWebProjectAgentController() { const hasUnsavedChange = hasFileContentChanged(selectedFile, editorContent); const isBuildBusy = isCreatingBuild || + isCancellingBuild || currentBuild?.status === 'queued' || currentBuild?.status === 'running'; + const canCancelBuild = isOpenRuntimeJob(currentRuntimeJob); + + const clearReconnectTimer = useCallback(() => { + if (reconnectTimeoutRef.current === null) { + return; + } + window.clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + }, []); + + const trackRuntimeJob = useCallback((job: WebProjectRuntimeJob | null) => { + if (job?.previewBuildId) { + runtimeJobIdByPreviewBuildRef.current[job.previewBuildId] = job.jobId; + } + setCurrentRuntimeJob(job); + }, []); const appendLog = useCallback((entry: Omit) => { setLogs((currentLogs) => [ @@ -240,6 +278,9 @@ function useWebProjectAgentController() { } clearPendingBuildJobId(build.projectId, build.jobId); + setCurrentRuntimeJob((job) => + job?.previewBuildId === build.jobId ? null : job, + ); if (build.status === 'succeeded' && build.previewUrl) { setActivePreviewUrl(build.previewUrl); @@ -258,7 +299,12 @@ function useWebProjectAgentController() { try { const build = await loadWebProjectPreviewBuild(jobId); setCurrentBuild(build); - setLogs(buildLogEntriesFromPreviewBuild(build)); + setLogs((currentLogs) => + mergeWebProjectAgentLogEntries( + currentLogs, + buildLogEntriesFromPreviewBuild(build), + ), + ); await handleTerminalBuild(build); return build; } catch (error) { @@ -269,8 +315,49 @@ function useWebProjectAgentController() { [handleTerminalBuild], ); + const loadRuntimeJobLogs = useCallback( + async (jobId: string, options: { reset?: boolean } = {}) => { + let afterSequence = options.reset + ? 0 + : runtimeLogCursorRef.current[jobId] ?? 0; + + if (options.reset) { + runtimeLogCursorRef.current[jobId] = 0; + } + + for (let pageIndex = 0; pageIndex < WEB_PROJECT_RUNTIME_LOG_MAX_BACKFILL_PAGES; pageIndex += 1) { + const response = await listWebProjectRuntimeJobLogs(jobId, { + afterSequence, + limit: WEB_PROJECT_RUNTIME_LOG_PAGE_LIMIT, + }); + const nextEntries = buildLogEntriesFromRuntimeJobLogs(response.logs); + if (nextEntries.length) { + setLogs((currentLogs) => + mergeWebProjectAgentLogEntries(currentLogs, nextEntries), + ); + } + + const nextAfterSequence = + response.nextAfterSequence ?? + response.logs[response.logs.length - 1]?.sequence ?? + afterSequence; + runtimeLogCursorRef.current[jobId] = Math.max( + runtimeLogCursorRef.current[jobId] ?? 0, + nextAfterSequence, + ); + afterSequence = nextAfterSequence; + + if (!response.hasMore || !response.logs.length) { + break; + } + } + }, + [], + ); + const subscribeToBuild = useCallback( (jobId: string) => { + clearReconnectTimer(); buildAbortControllerRef.current?.abort(); const controller = new AbortController(); buildAbortControllerRef.current = controller; @@ -297,6 +384,10 @@ function useWebProjectAgentController() { event.status === 'expired' || event.status === 'stale' ) { + const runtimeJobId = runtimeJobIdByPreviewBuildRef.current[event.jobId]; + if (runtimeJobId) { + void loadRuntimeJobLogs(runtimeJobId).catch(() => undefined); + } if (event.build) { void handleTerminalBuild(event.build); } else { @@ -309,11 +400,38 @@ function useWebProjectAgentController() { return; } setErrorMessage(resolveErrorMessage(error, '预览构建订阅中断')); - void refreshBuild(jobId); + void (async () => { + const runtimeJobId = runtimeJobIdByPreviewBuildRef.current[jobId]; + if (runtimeJobId) { + try { + await loadRuntimeJobLogs(runtimeJobId); + } catch { + // 订阅恢复时日志补齐失败不阻断 build 状态刷新。 + } + } + + const build = await refreshBuild(jobId); + if (!build || isTerminalWebProjectBuild(build) || controller.signal.aborted) { + return; + } + + reconnectTimeoutRef.current = window.setTimeout(() => { + reconnectTimeoutRef.current = null; + if (!controller.signal.aborted) { + subscribeToBuildRef.current(jobId); + } + }, WEB_PROJECT_SSE_RECONNECT_DELAY_MS); + })(); }); }, - [handleTerminalBuild, refreshBuild], + [ + clearReconnectTimer, + handleTerminalBuild, + loadRuntimeJobLogs, + refreshBuild, + ], ); + subscribeToBuildRef.current = subscribeToBuild; const loadInitialState = useCallback(async () => { setIsBootstrapping(true); @@ -339,14 +457,45 @@ function useWebProjectAgentController() { const activeBuildId = bundle.project.activePreviewBuildId?.trim(); if (activeBuildId) { const activeBuild = await loadWebProjectPreviewBuild(activeBuildId); - if (activeBuild.status === 'succeeded' && activeBuild.previewUrl) { - setActivePreviewUrl(activeBuild.previewUrl); + setCurrentBuild(activeBuild); + setLogs((currentLogs) => + mergeWebProjectAgentLogEntries( + currentLogs, + buildLogEntriesFromPreviewBuild(activeBuild), + ), + ); + await handleTerminalBuild(activeBuild); + if (!isTerminalWebProjectBuild(activeBuild)) { + subscribeToBuild(activeBuild.jobId); } } + let openRuntimeJob: WebProjectRuntimeJob | null = null; + try { + const openRuntimeJobs = await listOpenWebProjectRuntimeJobs( + bundle.project.projectId, + { limit: 5 }, + ); + openRuntimeJob = + openRuntimeJobs.find( + (job) => job.previewBuildId && isOpenRuntimeJob(job), + ) ?? null; + trackRuntimeJob(openRuntimeJob); + if (openRuntimeJob) { + await loadRuntimeJobLogs(openRuntimeJob.jobId, { + reset: true, + }).catch(() => undefined); + } + } catch { + trackRuntimeJob(null); + } + const pendingJobId = readPendingBuildJobId(bundle.project.projectId); - if (pendingJobId && pendingJobId !== activeBuildId) { - const pendingBuild = await refreshBuild(pendingJobId); + const restoreBuildId = + openRuntimeJob?.previewBuildId ?? + (pendingJobId && pendingJobId !== activeBuildId ? pendingJobId : null); + if (restoreBuildId && restoreBuildId !== activeBuildId) { + const pendingBuild = await refreshBuild(restoreBuildId); if (pendingBuild && !isTerminalWebProjectBuild(pendingBuild)) { subscribeToBuild(pendingBuild.jobId); } @@ -356,12 +505,23 @@ function useWebProjectAgentController() { } finally { setIsBootstrapping(false); } - }, [appendLog, applySnapshot, refreshBuild, subscribeToBuild]); + }, [ + appendLog, + applySnapshot, + handleTerminalBuild, + loadRuntimeJobLogs, + refreshBuild, + subscribeToBuild, + trackRuntimeJob, + ]); useEffect(() => { void loadInitialState(); - return () => buildAbortControllerRef.current?.abort(); - }, [loadInitialState]); + return () => { + clearReconnectTimer(); + buildAbortControllerRef.current?.abort(); + }; + }, [clearReconnectTimer, loadInitialState]); const saveCurrentFile = useCallback( async (options: { silent?: boolean } = {}) => { @@ -413,11 +573,21 @@ function useWebProjectAgentController() { setIsCreatingBuild(true); setErrorMessage(null); try { - const build = await createWebProjectPreviewBuild(project.projectId, { - snapshotId: targetSnapshot.snapshotId, - }); + const response = await createWebProjectPreviewBuildWithRuntimeJob( + project.projectId, + { + snapshotId: targetSnapshot.snapshotId, + }, + ); + const build = response.build; setCurrentBuild(build); + trackRuntimeJob(response.runtimeJob ?? null); setLogs(buildLogEntriesFromPreviewBuild(build)); + if (response.runtimeJob) { + await loadRuntimeJobLogs(response.runtimeJob.jobId, { + reset: true, + }).catch(() => undefined); + } writePendingBuildJobId(project.projectId, build.jobId); if (isTerminalWebProjectBuild(build)) { @@ -434,9 +604,47 @@ function useWebProjectAgentController() { setIsCreatingBuild(false); } }, - [handleTerminalBuild, project, subscribeToBuild], + [ + handleTerminalBuild, + loadRuntimeJobLogs, + project, + subscribeToBuild, + trackRuntimeJob, + ], ); + const handleCancelCurrentBuild = useCallback(async () => { + if (!currentRuntimeJob || !isOpenRuntimeJob(currentRuntimeJob)) { + return; + } + + setIsCancellingBuild(true); + setErrorMessage(null); + try { + const job = await cancelWebProjectRuntimeJob(currentRuntimeJob.jobId); + trackRuntimeJob(isOpenRuntimeJob(job) ? job : null); + appendLog({ tone: 'warning', text: '取消请求已发送' }); + await loadRuntimeJobLogs(currentRuntimeJob.jobId).catch(() => undefined); + if (job.previewBuildId) { + const build = await refreshBuild(job.previewBuildId); + if (build && !isTerminalWebProjectBuild(build)) { + subscribeToBuild(build.jobId); + } + } + } catch (error) { + setErrorMessage(resolveErrorMessage(error, '取消预览构建失败')); + } finally { + setIsCancellingBuild(false); + } + }, [ + appendLog, + currentRuntimeJob, + loadRuntimeJobLogs, + refreshBuild, + subscribeToBuild, + trackRuntimeJob, + ]); + const handleSaveCurrentFile = useCallback(async () => { await saveCurrentFile(); }, [saveCurrentFile]); @@ -495,6 +703,8 @@ function useWebProjectAgentController() { hasUnsavedChange, isBootstrapping, isBuildBusy, + canCancelBuild, + isCancellingBuild, isMockAgentBusy, isSaving, logs, @@ -508,6 +718,7 @@ function useWebProjectAgentController() { setMobileTab, setPrompt, handleCreatePreviewBuild, + handleCancelCurrentBuild, handleSaveCurrentFile, handleSubmitMockAgent, loadInitialState, @@ -685,14 +896,20 @@ function MockAgentPanel({ function PreviewPanel({ activePreviewUrl, buildStatusLabel, + canCancelBuild, disabled, + isCancellingBuild, isBuildBusy, + onCancelBuild, onCreatePreviewBuild, }: { activePreviewUrl: string | null; buildStatusLabel: string; + canCancelBuild: boolean; disabled: boolean; + isCancellingBuild: boolean; isBuildBusy: boolean; + onCancelBuild: () => void; onCreatePreviewBuild: () => void; }) { return ( @@ -708,6 +925,24 @@ function PreviewPanel({ {buildStatusLabel} + {canCancelBuild ? ( + + + {isCancellingBuild ? ( + + ) : ( + + )} + 取消 + + + ) : null} { + void controller.handleCancelCurrentBuild(); + }} onCreatePreviewBuild={() => { void controller.handleCreatePreviewBuild(); }} diff --git a/src/components/editor/agent/webProjectAgentViewModel.test.ts b/src/components/editor/agent/webProjectAgentViewModel.test.ts index 5596dfe0..8682f227 100644 --- a/src/components/editor/agent/webProjectAgentViewModel.test.ts +++ b/src/components/editor/agent/webProjectAgentViewModel.test.ts @@ -3,13 +3,16 @@ import { describe, expect, it, vi } from 'vitest'; import type { WebProjectFile, WebProjectPreviewBuild, + WebProjectRuntimeJobLog, } from '../../../../packages/shared/src/contracts/webProject'; import { + buildLogEntriesFromRuntimeJobLogs, createBuildLogEntry, createUpdateFilePatch, findWebProjectFile, hasFileContentChanged, isTerminalWebProjectBuild, + mergeWebProjectAgentLogEntries, mergeWebProjectBuildEvent, resolveInitialSelectedFilePath, sortWebProjectFiles, @@ -125,4 +128,49 @@ describe('webProjectAgentViewModel', () => { vi.useRealTimers(); }); + + it('maps persisted runtime job logs and deduplicates by stable id', () => { + const runtimeLogs: WebProjectRuntimeJobLog[] = [ + { + logId: 'log-1', + jobId: 'web-runtime-job-1', + projectId: 'web-project-1', + ownerUserId: 'user-1', + sequence: 1, + level: 'info', + message: 'hydrate snapshot', + createdAt: '2026-06-17T00:00:00.000Z', + }, + { + logId: 'log-2', + jobId: 'web-runtime-job-1', + projectId: 'web-project-1', + ownerUserId: 'user-1', + sequence: 2, + level: 'warning', + message: 'runner warning', + createdAt: '2026-06-17T00:00:01.000Z', + }, + ]; + + const entries = buildLogEntriesFromRuntimeJobLogs(runtimeLogs); + const merged = mergeWebProjectAgentLogEntries( + [entries[0]!], + entries, + ); + + expect(entries).toEqual([ + { + id: 'web-runtime-job-1:runtime-log:1', + tone: 'info', + text: 'hydrate snapshot', + }, + { + id: 'web-runtime-job-1:runtime-log:2', + tone: 'warning', + text: 'runner warning', + }, + ]); + expect(merged).toHaveLength(2); + }); }); diff --git a/src/components/editor/agent/webProjectAgentViewModel.ts b/src/components/editor/agent/webProjectAgentViewModel.ts index 386c0dc3..5389bec3 100644 --- a/src/components/editor/agent/webProjectAgentViewModel.ts +++ b/src/components/editor/agent/webProjectAgentViewModel.ts @@ -3,6 +3,7 @@ import type { WebProjectPatch, WebProjectPreviewBuild, WebProjectPreviewBuildEvent, + WebProjectRuntimeJobLog, } from '../../../../packages/shared/src/contracts/webProject'; const TERMINAL_BUILD_STATUSES = new Set([ @@ -132,3 +133,45 @@ export function buildLogEntriesFromPreviewBuild( text, })) satisfies WebProjectAgentLogEntry[]; } + +export function buildLogEntriesFromRuntimeJobLogs( + logs: readonly WebProjectRuntimeJobLog[], +) { + return logs.map((log) => ({ + id: `${log.jobId}:runtime-log:${log.sequence}`, + tone: resolveRuntimeJobLogTone(log.level), + text: log.message, + })) satisfies WebProjectAgentLogEntry[]; +} + +export function mergeWebProjectAgentLogEntries( + current: readonly WebProjectAgentLogEntry[], + incoming: readonly WebProjectAgentLogEntry[], +) { + const knownIds = new Set(current.map((entry) => entry.id)); + const merged = [...current]; + for (const entry of incoming) { + if (knownIds.has(entry.id)) { + continue; + } + knownIds.add(entry.id); + merged.push(entry); + } + return merged; +} + +function resolveRuntimeJobLogTone(level: string): WebProjectAgentLogEntry['tone'] { + switch (level.trim().toLowerCase()) { + case 'error': + case 'fatal': + return 'error'; + case 'warn': + case 'warning': + return 'warning'; + case 'success': + case 'succeeded': + return 'success'; + default: + return 'info'; + } +} diff --git a/src/services/web-project/webProjectClient.test.ts b/src/services/web-project/webProjectClient.test.ts index d87ff811..6f78bb1f 100644 --- a/src/services/web-project/webProjectClient.test.ts +++ b/src/services/web-project/webProjectClient.test.ts @@ -1,12 +1,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + cancelWebProjectRuntimeJob, createWebProject, createWebProjectPreviewBuild, + createWebProjectPreviewBuildWithRuntimeJob, createWebProjectWithSnapshot, + listOpenWebProjectRuntimeJobs, + listWebProjectRuntimeJobLogs, loadActiveWebProjectSnapshot, loadWebProject, loadWebProjectPreviewBuild, + loadWebProjectRuntimeJob, saveWebProjectFiles, submitMockAgentTurn, } from './webProjectClient'; @@ -143,6 +148,18 @@ describe('webProjectClient', () => { previewUrl: null, logs: [], }, + runtimeJob: { + jobId: 'web-runtime-job-1', + projectId: 'web-project-1', + snapshotId: 'web-snapshot-2', + ownerUserId: 'user-1', + jobKind: 'preview_build', + status: 'queued', + attempt: 0, + previewBuildId: 'web-build-1', + createdAt: '2026-06-17T00:00:00.000Z', + updatedAt: '2026-06-17T00:00:00.000Z', + }, }) .mockResolvedValueOnce({ build: { @@ -159,11 +176,13 @@ describe('webProjectClient', () => { prompt: '做一个蓝色计数按钮页面', baseSnapshotId: 'web-snapshot-1', }); - await createWebProjectPreviewBuild('web-project-1', { + const queuedBuild = await createWebProjectPreviewBuild('web-project-1', { snapshotId: 'web-snapshot-2', }); const build = await loadWebProjectPreviewBuild('web-build-1'); + expect(queuedBuild.jobId).toBe('web-build-1'); + expect(queuedBuild.status).toBe('queued'); expect(build.previewUrl).toBe('http://127.0.0.1:3999/p/token/'); expect(requestJsonMock).toHaveBeenNthCalledWith( 1, @@ -197,6 +216,99 @@ describe('webProjectClient', () => { ); }); + it('keeps runtime job metadata when creating preview builds through the runtime-aware helper', async () => { + requestJsonMock.mockResolvedValueOnce({ + build: { + jobId: 'web-build-1', + projectId: 'web-project-1', + snapshotId: 'web-snapshot-1', + status: 'queued', + logs: [], + }, + runtimeJob: { + jobId: 'web-runtime-job-1', + projectId: 'web-project-1', + snapshotId: 'web-snapshot-1', + ownerUserId: 'user-1', + jobKind: 'preview_build', + status: 'queued', + attempt: 0, + previewBuildId: 'web-build-1', + createdAt: '2026-06-17T00:00:00.000Z', + updatedAt: '2026-06-17T00:00:00.000Z', + }, + }); + + const response = await createWebProjectPreviewBuildWithRuntimeJob( + 'web-project-1', + { snapshotId: 'web-snapshot-1' }, + ); + + expect(response.build.jobId).toBe('web-build-1'); + expect(response.runtimeJob?.jobId).toBe('web-runtime-job-1'); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/runtime/web-project/projects/web-project-1/preview-builds', + expect.objectContaining({ method: 'POST' }), + '创建 Web 工程预览构建失败', + expect.any(Object), + ); + }); + + it('reads, cancels and lists runtime jobs and logs through runtime endpoints', async () => { + requestJsonMock + .mockResolvedValueOnce({ + job: { jobId: 'web-runtime-job-1', status: 'running' }, + }) + .mockResolvedValueOnce({ + jobs: [{ jobId: 'web-runtime-job-1', status: 'running' }], + }) + .mockResolvedValueOnce({ + job: { jobId: 'web-runtime-job-1', status: 'cancelled' }, + }) + .mockResolvedValueOnce({ + logs: [{ logId: 'log-1', sequence: 1 }], + nextAfterSequence: 1, + hasMore: false, + }); + + await loadWebProjectRuntimeJob('web-runtime-job-1'); + await listOpenWebProjectRuntimeJobs('web-project-1', { limit: 5 }); + await cancelWebProjectRuntimeJob('web-runtime-job-1'); + await listWebProjectRuntimeJobLogs('web-runtime-job-1', { + afterSequence: 1, + limit: 20, + }); + + expect(requestJsonMock).toHaveBeenNthCalledWith( + 1, + '/api/runtime/web-project/runtime-jobs/web-runtime-job-1', + { method: 'GET' }, + '读取 Web 工程运行任务失败', + expect.any(Object), + ); + expect(requestJsonMock).toHaveBeenNthCalledWith( + 2, + '/api/runtime/web-project/projects/web-project-1/runtime-jobs?limit=5', + { method: 'GET' }, + '读取 Web 工程运行任务列表失败', + expect.any(Object), + ); + expect(requestJsonMock).toHaveBeenNthCalledWith( + 3, + '/api/runtime/web-project/runtime-jobs/web-runtime-job-1/cancel', + { method: 'POST' }, + '取消 Web 工程运行任务失败', + expect.any(Object), + ); + expect(requestJsonMock).toHaveBeenNthCalledWith( + 4, + '/api/runtime/web-project/runtime-jobs/web-runtime-job-1/logs?afterSequence=1&limit=20', + { method: 'GET' }, + '读取 Web 工程运行任务日志失败', + expect.any(Object), + ); + }); + it('creates a project and immediately reads its active snapshot', async () => { requestJsonMock .mockResolvedValueOnce({ diff --git a/src/services/web-project/webProjectClient.ts b/src/services/web-project/webProjectClient.ts index c15ec29b..c264d893 100644 --- a/src/services/web-project/webProjectClient.ts +++ b/src/services/web-project/webProjectClient.ts @@ -2,8 +2,11 @@ import type { MockAgentTurnResponse, WebProject, WebProjectPatch, - WebProjectPreviewBuild, + WebProjectPreviewBuildResponse, WebProjectResponse, + WebProjectRuntimeJobListResponse, + WebProjectRuntimeJobLogListResponse, + WebProjectRuntimeJobResponse, WebProjectSnapshot, WebProjectSnapshotResponse, } from '../../../packages/shared/src/contracts/webProject'; @@ -17,10 +20,6 @@ const WEB_PROJECT_REQUEST_OPTIONS = { notifyAuthStateChange: false, } satisfies ApiRequestOptions; -type WebProjectPreviewBuildResponse = { - build: WebProjectPreviewBuild; -}; - function jsonRequest(method: 'POST' | 'PATCH', body: Record) { return { method, @@ -105,6 +104,14 @@ export async function submitMockAgentTurn( export async function createWebProjectPreviewBuild( projectId: string, input: { snapshotId?: string } = {}, +) { + const response = await createWebProjectPreviewBuildWithRuntimeJob(projectId, input); + return response.build; +} + +export async function createWebProjectPreviewBuildWithRuntimeJob( + projectId: string, + input: { snapshotId?: string } = {}, ) { const response = await requestJson( `${WEB_PROJECT_RUNTIME_API_BASE}/projects/${encodeURIComponent(projectId)}/preview-builds`, @@ -112,7 +119,7 @@ export async function createWebProjectPreviewBuild( '创建 Web 工程预览构建失败', WEB_PROJECT_REQUEST_OPTIONS, ); - return response.build; + return response; } export async function loadWebProjectPreviewBuild(jobId: string) { @@ -125,6 +132,68 @@ export async function loadWebProjectPreviewBuild(jobId: string) { return response.build; } +export async function loadWebProjectRuntimeJob(jobId: string) { + const response = await requestJson( + `${WEB_PROJECT_RUNTIME_API_BASE}/runtime-jobs/${encodeURIComponent(jobId)}`, + { method: 'GET' }, + '读取 Web 工程运行任务失败', + WEB_PROJECT_REQUEST_OPTIONS, + ); + return response.job; +} + +export async function listOpenWebProjectRuntimeJobs( + projectId: string, + input: { limit?: number } = {}, +) { + const params = new URLSearchParams(); + if (typeof input.limit === 'number') { + params.set('limit', String(input.limit)); + } + const query = params.toString(); + const response = await requestJson( + `${WEB_PROJECT_RUNTIME_API_BASE}/projects/${encodeURIComponent(projectId)}/runtime-jobs${ + query ? `?${query}` : '' + }`, + { method: 'GET' }, + '读取 Web 工程运行任务列表失败', + WEB_PROJECT_REQUEST_OPTIONS, + ); + return response.jobs; +} + +export async function cancelWebProjectRuntimeJob(jobId: string) { + const response = await requestJson( + `${WEB_PROJECT_RUNTIME_API_BASE}/runtime-jobs/${encodeURIComponent(jobId)}/cancel`, + { method: 'POST' }, + '取消 Web 工程运行任务失败', + WEB_PROJECT_REQUEST_OPTIONS, + ); + return response.job; +} + +export async function listWebProjectRuntimeJobLogs( + jobId: string, + input: { afterSequence?: number; limit?: number } = {}, +) { + const params = new URLSearchParams(); + if (typeof input.afterSequence === 'number') { + params.set('afterSequence', String(input.afterSequence)); + } + if (typeof input.limit === 'number') { + params.set('limit', String(input.limit)); + } + const query = params.toString(); + return requestJson( + `${WEB_PROJECT_RUNTIME_API_BASE}/runtime-jobs/${encodeURIComponent(jobId)}/logs${ + query ? `?${query}` : '' + }`, + { method: 'GET' }, + '读取 Web 工程运行任务日志失败', + WEB_PROJECT_REQUEST_OPTIONS, + ); +} + export type WebProjectClientSnapshotBundle = { project: WebProject; snapshot: WebProjectSnapshot; diff --git a/src/services/web-project/webProjectSse.test.ts b/src/services/web-project/webProjectSse.test.ts index 3fef044d..782340aa 100644 --- a/src/services/web-project/webProjectSse.test.ts +++ b/src/services/web-project/webProjectSse.test.ts @@ -102,4 +102,29 @@ describe('webProjectSse', () => { }), ); }); + + it('stops after stale terminal status', async () => { + fetchWithApiAuthMock.mockResolvedValueOnce( + createSseResponse( + [ + 'event: message', + 'data: {"jobId":"web-build-1","status":"running","message":"构建中"}', + '', + 'event: message', + 'data: {"jobId":"web-build-1","status":"stale","message":"已过时"}', + '', + 'event: message', + 'data: {"jobId":"web-build-1","status":"succeeded","message":"不应继续消费"}', + '', + ].join('\n'), + ), + ); + + const events: string[] = []; + await subscribeWebProjectPreviewBuildEvents('web-build-1', { + onEvent: (event) => events.push(`${event.status}:${event.message}`), + }); + + expect(events).toEqual(['running:构建中', 'stale:已过时']); + }); });