diff --git a/.codex/README.md b/.codex/README.md new file mode 100644 index 000000000..4ea0ba9a9 --- /dev/null +++ b/.codex/README.md @@ -0,0 +1,16 @@ +# Genarrative Codex 项目工具 + +`.codex/` 是仓库级 Codex 工具目录,保存项目共享的 skills、插件资源、hooks 和相关配置模板。它只描述如何协作和加载工具,不承载项目业务知识。 + +## 目录约定 + +- `.codex/skills/` 是项目专属 skill 根目录。每个 skill 以目录中的 `SKILL.md` 为入口,配套的参考资料和脚本放在同一目录下。 +- `.codex/plugins/` 保存随仓库分发的项目插件资源及其参考资料。当前的 `game-studio` 插件提供浏览器游戏设计、原型、2D/3D 技术栈、素材管线和 playtest 工作流;是否启用遵循当前 Codex 的插件加载机制,不依赖旧工具的环境变量或个人配置脚本。 +- `.codex/hooks/`、`.codex/environments/` 等目录保存项目工具链所需的 hooks 和环境模板;它们不替代项目代码中的运行时配置。 +- 长期有效的产品、架构、接口、排障和协作知识统一放在 `docs/` 与 `docs/project-memory/`,不复制到本目录。 + +## 使用边界 + +进入仓库后先读根目录 `AGENTS.md`,再按任务路由读取对应 skill。SpacetimeDB 的通用概念、Rust 服务端、CLI、TypeScript 客户端和 MCP 用法由已安装的官方插件提供;项目约束和入口由 `.codex/skills/genarrative-spacetimedb/SKILL.md` 统一编排。 + +个人 `~/.codex` 配置、凭据、会话、环境变量和本地路径不得复制到仓库。若本目录内容与当前代码或最新 `docs/` 冲突,以代码和最新文档为准,并修正过期工具说明。 diff --git a/.hermes/plugins/game-studio/.codex-plugin/plugin.json b/.codex/plugins/game-studio/.codex-plugin/plugin.json similarity index 100% rename from .hermes/plugins/game-studio/.codex-plugin/plugin.json rename to .codex/plugins/game-studio/.codex-plugin/plugin.json diff --git a/.hermes/plugins/game-studio/__init__.py b/.codex/plugins/game-studio/__init__.py similarity index 76% rename from .hermes/plugins/game-studio/__init__.py rename to .codex/plugins/game-studio/__init__.py index bcf37e070..b7ea43159 100644 --- a/.hermes/plugins/game-studio/__init__.py +++ b/.codex/plugins/game-studio/__init__.py @@ -1,8 +1,8 @@ -"""Hermes wrapper for the OpenAI Codex Game Studio plugin. +"""Metadata bridge for the bundled Codex Game Studio project plugin. -This plugin was imported from a Codex curated plugin cache. It exposes the -plugin's bundled SKILL.md files as Hermes plugin skills using qualified names -like `game-studio:phaser-2d-game`. +The manifest and bundled ``SKILL.md`` files are the source of truth. This +module only exposes their descriptions to a compatible plugin host and keeps +the project resource bundle free of runtime-specific behavior. """ from __future__ import annotations diff --git a/.hermes/plugins/game-studio/assets/app-icon.png b/.codex/plugins/game-studio/assets/app-icon.png similarity index 100% rename from .hermes/plugins/game-studio/assets/app-icon.png rename to .codex/plugins/game-studio/assets/app-icon.png diff --git a/.hermes/plugins/game-studio/assets/game-studio.svg b/.codex/plugins/game-studio/assets/game-studio.svg similarity index 100% rename from .hermes/plugins/game-studio/assets/game-studio.svg rename to .codex/plugins/game-studio/assets/game-studio.svg diff --git a/.hermes/plugins/game-studio/plugin.yaml b/.codex/plugins/game-studio/plugin.yaml similarity index 100% rename from .hermes/plugins/game-studio/plugin.yaml rename to .codex/plugins/game-studio/plugin.yaml diff --git a/.hermes/plugins/game-studio/references/alternative-3d-engines.md b/.codex/plugins/game-studio/references/alternative-3d-engines.md similarity index 100% rename from .hermes/plugins/game-studio/references/alternative-3d-engines.md rename to .codex/plugins/game-studio/references/alternative-3d-engines.md diff --git a/.hermes/plugins/game-studio/references/engine-selection.md b/.codex/plugins/game-studio/references/engine-selection.md similarity index 100% rename from .hermes/plugins/game-studio/references/engine-selection.md rename to .codex/plugins/game-studio/references/engine-selection.md diff --git a/.hermes/plugins/game-studio/references/frontend-prompts.md b/.codex/plugins/game-studio/references/frontend-prompts.md similarity index 100% rename from .hermes/plugins/game-studio/references/frontend-prompts.md rename to .codex/plugins/game-studio/references/frontend-prompts.md diff --git a/.hermes/plugins/game-studio/references/gltf-loading-starter.md b/.codex/plugins/game-studio/references/gltf-loading-starter.md similarity index 100% rename from .hermes/plugins/game-studio/references/gltf-loading-starter.md rename to .codex/plugins/game-studio/references/gltf-loading-starter.md diff --git a/.hermes/plugins/game-studio/references/phaser-architecture.md b/.codex/plugins/game-studio/references/phaser-architecture.md similarity index 100% rename from .hermes/plugins/game-studio/references/phaser-architecture.md rename to .codex/plugins/game-studio/references/phaser-architecture.md diff --git a/.hermes/plugins/game-studio/references/playtest-checklist.md b/.codex/plugins/game-studio/references/playtest-checklist.md similarity index 100% rename from .hermes/plugins/game-studio/references/playtest-checklist.md rename to .codex/plugins/game-studio/references/playtest-checklist.md diff --git a/.hermes/plugins/game-studio/references/rapier-integration-starter.md b/.codex/plugins/game-studio/references/rapier-integration-starter.md similarity index 100% rename from .hermes/plugins/game-studio/references/rapier-integration-starter.md rename to .codex/plugins/game-studio/references/rapier-integration-starter.md diff --git a/.hermes/plugins/game-studio/references/react-three-fiber-stack.md b/.codex/plugins/game-studio/references/react-three-fiber-stack.md similarity index 100% rename from .hermes/plugins/game-studio/references/react-three-fiber-stack.md rename to .codex/plugins/game-studio/references/react-three-fiber-stack.md diff --git a/.hermes/plugins/game-studio/references/react-three-fiber-starter.md b/.codex/plugins/game-studio/references/react-three-fiber-starter.md similarity index 100% rename from .hermes/plugins/game-studio/references/react-three-fiber-starter.md rename to .codex/plugins/game-studio/references/react-three-fiber-starter.md diff --git a/.hermes/plugins/game-studio/references/sprite-pipeline.md b/.codex/plugins/game-studio/references/sprite-pipeline.md similarity index 100% rename from .hermes/plugins/game-studio/references/sprite-pipeline.md rename to .codex/plugins/game-studio/references/sprite-pipeline.md diff --git a/.hermes/plugins/game-studio/references/three-hud-layout-patterns.md b/.codex/plugins/game-studio/references/three-hud-layout-patterns.md similarity index 100% rename from .hermes/plugins/game-studio/references/three-hud-layout-patterns.md rename to .codex/plugins/game-studio/references/three-hud-layout-patterns.md diff --git a/.hermes/plugins/game-studio/references/three-webgl-architecture.md b/.codex/plugins/game-studio/references/three-webgl-architecture.md similarity index 100% rename from .hermes/plugins/game-studio/references/three-webgl-architecture.md rename to .codex/plugins/game-studio/references/three-webgl-architecture.md diff --git a/.hermes/plugins/game-studio/references/threejs-stack.md b/.codex/plugins/game-studio/references/threejs-stack.md similarity index 100% rename from .hermes/plugins/game-studio/references/threejs-stack.md rename to .codex/plugins/game-studio/references/threejs-stack.md diff --git a/.hermes/plugins/game-studio/references/threejs-vanilla-starter.md b/.codex/plugins/game-studio/references/threejs-vanilla-starter.md similarity index 100% rename from .hermes/plugins/game-studio/references/threejs-vanilla-starter.md rename to .codex/plugins/game-studio/references/threejs-vanilla-starter.md diff --git a/.hermes/plugins/game-studio/references/web-3d-asset-pipeline.md b/.codex/plugins/game-studio/references/web-3d-asset-pipeline.md similarity index 100% rename from .hermes/plugins/game-studio/references/web-3d-asset-pipeline.md rename to .codex/plugins/game-studio/references/web-3d-asset-pipeline.md diff --git a/.hermes/plugins/game-studio/references/webgl-debugging-and-performance.md b/.codex/plugins/game-studio/references/webgl-debugging-and-performance.md similarity index 100% rename from .hermes/plugins/game-studio/references/webgl-debugging-and-performance.md rename to .codex/plugins/game-studio/references/webgl-debugging-and-performance.md diff --git a/.hermes/plugins/game-studio/scripts/build_sprite_edit_canvas.py b/.codex/plugins/game-studio/scripts/build_sprite_edit_canvas.py similarity index 100% rename from .hermes/plugins/game-studio/scripts/build_sprite_edit_canvas.py rename to .codex/plugins/game-studio/scripts/build_sprite_edit_canvas.py diff --git a/.hermes/plugins/game-studio/scripts/normalize_sprite_strip.py b/.codex/plugins/game-studio/scripts/normalize_sprite_strip.py similarity index 100% rename from .hermes/plugins/game-studio/scripts/normalize_sprite_strip.py rename to .codex/plugins/game-studio/scripts/normalize_sprite_strip.py diff --git a/.hermes/plugins/game-studio/scripts/render_sprite_preview_sheet.py b/.codex/plugins/game-studio/scripts/render_sprite_preview_sheet.py similarity index 100% rename from .hermes/plugins/game-studio/scripts/render_sprite_preview_sheet.py rename to .codex/plugins/game-studio/scripts/render_sprite_preview_sheet.py diff --git a/.hermes/plugins/game-studio/skills/game-playtest/SKILL.md b/.codex/plugins/game-studio/skills/game-playtest/SKILL.md similarity index 100% rename from .hermes/plugins/game-studio/skills/game-playtest/SKILL.md rename to .codex/plugins/game-studio/skills/game-playtest/SKILL.md diff --git a/.hermes/plugins/game-studio/skills/game-playtest/agents/openai.yaml b/.codex/plugins/game-studio/skills/game-playtest/agents/openai.yaml similarity index 100% rename from .hermes/plugins/game-studio/skills/game-playtest/agents/openai.yaml rename to .codex/plugins/game-studio/skills/game-playtest/agents/openai.yaml diff --git a/.hermes/plugins/game-studio/skills/game-studio/SKILL.md b/.codex/plugins/game-studio/skills/game-studio/SKILL.md similarity index 100% rename from .hermes/plugins/game-studio/skills/game-studio/SKILL.md rename to .codex/plugins/game-studio/skills/game-studio/SKILL.md diff --git a/.hermes/plugins/game-studio/skills/game-studio/agents/openai.yaml b/.codex/plugins/game-studio/skills/game-studio/agents/openai.yaml similarity index 100% rename from .hermes/plugins/game-studio/skills/game-studio/agents/openai.yaml rename to .codex/plugins/game-studio/skills/game-studio/agents/openai.yaml diff --git a/.hermes/plugins/game-studio/skills/game-ui-frontend/SKILL.md b/.codex/plugins/game-studio/skills/game-ui-frontend/SKILL.md similarity index 100% rename from .hermes/plugins/game-studio/skills/game-ui-frontend/SKILL.md rename to .codex/plugins/game-studio/skills/game-ui-frontend/SKILL.md diff --git a/.hermes/plugins/game-studio/skills/game-ui-frontend/agents/openai.yaml b/.codex/plugins/game-studio/skills/game-ui-frontend/agents/openai.yaml similarity index 100% rename from .hermes/plugins/game-studio/skills/game-ui-frontend/agents/openai.yaml rename to .codex/plugins/game-studio/skills/game-ui-frontend/agents/openai.yaml diff --git a/.hermes/plugins/game-studio/skills/phaser-2d-game/SKILL.md b/.codex/plugins/game-studio/skills/phaser-2d-game/SKILL.md similarity index 100% rename from .hermes/plugins/game-studio/skills/phaser-2d-game/SKILL.md rename to .codex/plugins/game-studio/skills/phaser-2d-game/SKILL.md diff --git a/.hermes/plugins/game-studio/skills/phaser-2d-game/agents/openai.yaml b/.codex/plugins/game-studio/skills/phaser-2d-game/agents/openai.yaml similarity index 100% rename from .hermes/plugins/game-studio/skills/phaser-2d-game/agents/openai.yaml rename to .codex/plugins/game-studio/skills/phaser-2d-game/agents/openai.yaml diff --git a/.hermes/plugins/game-studio/skills/react-three-fiber-game/SKILL.md b/.codex/plugins/game-studio/skills/react-three-fiber-game/SKILL.md similarity index 100% rename from .hermes/plugins/game-studio/skills/react-three-fiber-game/SKILL.md rename to .codex/plugins/game-studio/skills/react-three-fiber-game/SKILL.md diff --git a/.hermes/plugins/game-studio/skills/react-three-fiber-game/agents/openai.yaml b/.codex/plugins/game-studio/skills/react-three-fiber-game/agents/openai.yaml similarity index 100% rename from .hermes/plugins/game-studio/skills/react-three-fiber-game/agents/openai.yaml rename to .codex/plugins/game-studio/skills/react-three-fiber-game/agents/openai.yaml diff --git a/.hermes/plugins/game-studio/skills/sprite-pipeline/SKILL.md b/.codex/plugins/game-studio/skills/sprite-pipeline/SKILL.md similarity index 100% rename from .hermes/plugins/game-studio/skills/sprite-pipeline/SKILL.md rename to .codex/plugins/game-studio/skills/sprite-pipeline/SKILL.md diff --git a/.hermes/plugins/game-studio/skills/sprite-pipeline/agents/openai.yaml b/.codex/plugins/game-studio/skills/sprite-pipeline/agents/openai.yaml similarity index 100% rename from .hermes/plugins/game-studio/skills/sprite-pipeline/agents/openai.yaml rename to .codex/plugins/game-studio/skills/sprite-pipeline/agents/openai.yaml diff --git a/.hermes/plugins/game-studio/skills/three-webgl-game/SKILL.md b/.codex/plugins/game-studio/skills/three-webgl-game/SKILL.md similarity index 100% rename from .hermes/plugins/game-studio/skills/three-webgl-game/SKILL.md rename to .codex/plugins/game-studio/skills/three-webgl-game/SKILL.md diff --git a/.hermes/plugins/game-studio/skills/three-webgl-game/agents/openai.yaml b/.codex/plugins/game-studio/skills/three-webgl-game/agents/openai.yaml similarity index 100% rename from .hermes/plugins/game-studio/skills/three-webgl-game/agents/openai.yaml rename to .codex/plugins/game-studio/skills/three-webgl-game/agents/openai.yaml diff --git a/.hermes/plugins/game-studio/skills/web-3d-asset-pipeline/SKILL.md b/.codex/plugins/game-studio/skills/web-3d-asset-pipeline/SKILL.md similarity index 100% rename from .hermes/plugins/game-studio/skills/web-3d-asset-pipeline/SKILL.md rename to .codex/plugins/game-studio/skills/web-3d-asset-pipeline/SKILL.md diff --git a/.hermes/plugins/game-studio/skills/web-3d-asset-pipeline/agents/openai.yaml b/.codex/plugins/game-studio/skills/web-3d-asset-pipeline/agents/openai.yaml similarity index 100% rename from .hermes/plugins/game-studio/skills/web-3d-asset-pipeline/agents/openai.yaml rename to .codex/plugins/game-studio/skills/web-3d-asset-pipeline/agents/openai.yaml diff --git a/.hermes/plugins/game-studio/skills/web-game-foundations/SKILL.md b/.codex/plugins/game-studio/skills/web-game-foundations/SKILL.md similarity index 100% rename from .hermes/plugins/game-studio/skills/web-game-foundations/SKILL.md rename to .codex/plugins/game-studio/skills/web-game-foundations/SKILL.md diff --git a/.hermes/plugins/game-studio/skills/web-game-foundations/agents/openai.yaml b/.codex/plugins/game-studio/skills/web-game-foundations/agents/openai.yaml similarity index 100% rename from .hermes/plugins/game-studio/skills/web-game-foundations/agents/openai.yaml rename to .codex/plugins/game-studio/skills/web-game-foundations/agents/openai.yaml diff --git a/.codex/skills/README.md b/.codex/skills/README.md new file mode 100644 index 000000000..407ed1cd6 --- /dev/null +++ b/.codex/skills/README.md @@ -0,0 +1,7 @@ +# Genarrative 项目 skills + +`.codex/skills/` 是仓库级、可通过 Git 共享的项目专属 skill 根目录。每个目录的 `SKILL.md` 是唯一入口;较长的参考资料、示例和脚本放在该 skill 的 `references/`、`assets/` 或 `scripts/` 下。 + +项目 skill 负责把当前仓库的目录、契约、验证门禁和历史约束串起来,不重复维护通用框架知识。涉及 SpacetimeDB 时,先读 `genarrative-spacetimedb/SKILL.md`,由它路由到已安装的官方 SpacetimeDB 插件 skills。 + +长期项目知识放在 `docs/` 与 `docs/project-memory/`;不要把个人配置、密钥、会话、缓存或临时计划写入本目录。发现 skill 与代码或最新文档不一致时,按当前实现更新 skill,并同步必要的项目文档。 diff --git a/.hermes/skills/behavior-driven-development/SKILL.md b/.codex/skills/behavior-driven-development/SKILL.md similarity index 97% rename from .hermes/skills/behavior-driven-development/SKILL.md rename to .codex/skills/behavior-driven-development/SKILL.md index 5b3e4e674..f2e94eb80 100644 --- a/.hermes/skills/behavior-driven-development/SKILL.md +++ b/.codex/skills/behavior-driven-development/SKILL.md @@ -1,11 +1,9 @@ --- name: behavior-driven-development description: 在 Genarrative 中需要用 BDD/行为驱动方式把 PRD、用户故事、验收标准转成可执行场景、Gherkin 用例、测试计划或 TDD 落地顺序时使用。 -version: 1.0.0 -author: Hermes Agent license: MIT metadata: - hermes: + codex: tags: [BDD, Gherkin, 验收标准, 用户故事, 测试, Genarrative] related_skills: [writing-plans, test-driven-development, systematic-debugging, requesting-code-review] --- @@ -280,11 +278,11 @@ fn anonymous_user_cannot_publish_generated_draft() { | 产物类型 | 推荐路径 | 适用场景 | | --- | --- | --- | -| 实施前分析 / 临时计划 | 当前任务说明或 `.tmp/-bdd-scenarios.md` | 某次 Hermes 开发任务前,用于澄清行为、拆测试、辅助实现;不作为长期产品依据。 | +| 实施前分析 / 临时计划 | 当前任务说明或 `.tmp/-bdd-scenarios.md` | 某次 Codex 开发任务前,用于澄清行为、拆测试、辅助实现;不作为长期产品依据。 | | 正式产品验收 / PRD 场景 | 当前 `docs/` 融合文档,必要时新增 `docs/【产品验收】<功能名>BDD场景-YYYY-MM-DD.md` | 产品、测试、开发都需要长期参考的验收标准、用户故事、功能边界。 | | 技术/API/领域行为场景 | 当前 `docs/` 融合文档,必要时新增 `docs/【技术验收】<功能名>BDD场景-YYYY-MM-DD.md` | 后端 API、领域规则、状态机、SpacetimeDB reducer/table、SSE/异步任务、埋点副作用。 | | 自动化 Gherkin feature 文件 | `tests/features/*.feature` 或 `e2e/features/*.feature` | 项目已接入 Cucumber/Playwright BDD 等 Gherkin runner 时。未接入前不要随意新建测试 runner 目录。 | -| 稳定流程或团队经验 | `docs/project-memory/shared-memory/` 或 `.hermes/skills/` | 不是某个功能验收,而是长期可复用的团队流程、坑点、执行规范。 | +| 稳定流程或团队经验 | `docs/project-memory/shared-memory/` 或 `.codex/skills/` | 不是某个功能验收,而是长期可复用的团队流程、坑点、执行规范。 | 默认规则: @@ -311,7 +309,7 @@ e2e/features/invite-code.feature - 实施计划:当前任务上下文或 `.tmp/.md` - 产品/验收文档:当前 `docs/` 融合文档,必要时新增 `docs/【产品验收】中文标题-YYYY-MM-DD.md` - 技术设计:当前 `docs/` 融合文档,必要时新增 `docs/【技术方案】中文标题-YYYY-MM-DD.md` -- 共享经验或稳定流程:`docs/project-memory/shared-memory/` 或 `.hermes/skills/` +- 共享经验或稳定流程:`docs/project-memory/shared-memory/` 或 `.codex/skills/` BDD 文档建议包含: diff --git a/.hermes/skills/genarrative-admin-backoffice/SKILL.md b/.codex/skills/genarrative-admin-backoffice/SKILL.md similarity index 97% rename from .hermes/skills/genarrative-admin-backoffice/SKILL.md rename to .codex/skills/genarrative-admin-backoffice/SKILL.md index 24adcbf38..5c826f743 100644 --- a/.hermes/skills/genarrative-admin-backoffice/SKILL.md +++ b/.codex/skills/genarrative-admin-backoffice/SKILL.md @@ -1,12 +1,9 @@ --- name: genarrative-admin-backoffice -short_description: 在 Genarrative/陶泥儿后台新增或修改管理页、后台只读/写接口、导出能力时使用。 description: 在 Genarrative/陶泥儿后台新增或修改管理页、后台 BFF 接口、shared-contracts/admin DTO、admin-web 路由导航、Excel/表格导出与验证发布时使用。 -version: 1.0.0 -author: Hermes Agent license: MIT metadata: - hermes: + codex: tags: [Genarrative, 陶泥儿后台, admin-web, 后台接口, Excel导出, Rust, Axum, SpacetimeDB] related_skills: [genarrative-play-type-integration] --- @@ -168,7 +165,7 @@ npm run dev - 本地和人工排障不再使用 `spacetime --root-dir`。如果看到 `bin/current/spacetimedb-cli` 缺失类错误,优先确认是否仍在运行旧脚本或旧发布包;本地开发应使用 `npm run dev` / `npm run dev:spacetime`,通过项目脚本和 `--data-dir` 隔离 SpacetimeDB 数据目录,不再把用户级 SpacetimeDB 安装同步到项目目录。 - `scripts/dev.mjs` 默认 `api timeout: 600s`. 合并 master 后首次 Rust 依赖/工作区重编译可能超过默认等待窗口,导致完整 `npm run dev` 在 api-server 就绪前超时并回收 SpacetimeDB。先让 Rust 编译完成,或临时用 `npm run dev:api-server -- --api-timeout-seconds 900` 预热 api-server 编译;之后再重新跑完整 `npm run dev`。 -- 用户贴出的 Hermes background watch 通知可能来自已退出的旧 session。先用 `process poll` 查该 session 状态,再判断是否需要处理;不要把旧失败误判成当前服务失败。 +- 用户贴出的 Codex background watch 通知可能来自已退出的旧 session。先用 `process poll` 查该 session 状态,再判断是否需要处理;不要把旧失败误判成当前服务失败。 ## 测试与验证 diff --git a/.hermes/skills/genarrative-admin-backoffice/references/admin-database-table-query-2026-05-08.md b/.codex/skills/genarrative-admin-backoffice/references/admin-database-table-query-2026-05-08.md similarity index 100% rename from .hermes/skills/genarrative-admin-backoffice/references/admin-database-table-query-2026-05-08.md rename to .codex/skills/genarrative-admin-backoffice/references/admin-database-table-query-2026-05-08.md diff --git a/.hermes/skills/genarrative-admin-backoffice/references/admin-tracking-events-export-2026-05-07.md b/.codex/skills/genarrative-admin-backoffice/references/admin-tracking-events-export-2026-05-07.md similarity index 100% rename from .hermes/skills/genarrative-admin-backoffice/references/admin-tracking-events-export-2026-05-07.md rename to .codex/skills/genarrative-admin-backoffice/references/admin-tracking-events-export-2026-05-07.md diff --git a/.hermes/skills/genarrative-admin-backoffice/references/daily-login-auth-closure.md b/.codex/skills/genarrative-admin-backoffice/references/daily-login-auth-closure.md similarity index 100% rename from .hermes/skills/genarrative-admin-backoffice/references/daily-login-auth-closure.md rename to .codex/skills/genarrative-admin-backoffice/references/daily-login-auth-closure.md diff --git a/.hermes/skills/genarrative-admin-backoffice/references/daily-login-tracking-trigger-points.md b/.codex/skills/genarrative-admin-backoffice/references/daily-login-tracking-trigger-points.md similarity index 100% rename from .hermes/skills/genarrative-admin-backoffice/references/daily-login-tracking-trigger-points.md rename to .codex/skills/genarrative-admin-backoffice/references/daily-login-tracking-trigger-points.md diff --git a/.hermes/skills/genarrative-admin-backoffice/references/dev-rust-stack-startup-2026-05-08.md b/.codex/skills/genarrative-admin-backoffice/references/dev-rust-stack-startup-2026-05-08.md similarity index 100% rename from .hermes/skills/genarrative-admin-backoffice/references/dev-rust-stack-startup-2026-05-08.md rename to .codex/skills/genarrative-admin-backoffice/references/dev-rust-stack-startup-2026-05-08.md diff --git a/.hermes/skills/genarrative-admin-backoffice/references/private-table-sql-token-refresh.md b/.codex/skills/genarrative-admin-backoffice/references/private-table-sql-token-refresh.md similarity index 100% rename from .hermes/skills/genarrative-admin-backoffice/references/private-table-sql-token-refresh.md rename to .codex/skills/genarrative-admin-backoffice/references/private-table-sql-token-refresh.md diff --git a/.hermes/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md b/.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md similarity index 100% rename from .hermes/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md rename to .codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md diff --git a/.hermes/skills/genarrative-auth-session-flow/SKILL.md b/.codex/skills/genarrative-auth-session-flow/SKILL.md similarity index 99% rename from .hermes/skills/genarrative-auth-session-flow/SKILL.md rename to .codex/skills/genarrative-auth-session-flow/SKILL.md index 4644306fd..7e49913a1 100644 --- a/.hermes/skills/genarrative-auth-session-flow/SKILL.md +++ b/.codex/skills/genarrative-auth-session-flow/SKILL.md @@ -1,11 +1,9 @@ --- name: genarrative-auth-session-flow description: 在 Genarrative 中排查或修改登录、access token、refresh cookie、AuthGate 会话恢复、登录态刷新、认证埋点链路时使用。 -version: 1.0.0 -author: Hermes Agent license: MIT metadata: - hermes: + codex: tags: [Genarrative, auth, session, cookie, refresh-token, AuthGate, tracking] related_skills: [systematic-debugging, test-driven-development, genarrative-profile-features] --- diff --git a/.hermes/skills/genarrative-auth-session-flow/references/session-restore-daily-login-tracking-2026-05-08.md b/.codex/skills/genarrative-auth-session-flow/references/session-restore-daily-login-tracking-2026-05-08.md similarity index 100% rename from .hermes/skills/genarrative-auth-session-flow/references/session-restore-daily-login-tracking-2026-05-08.md rename to .codex/skills/genarrative-auth-session-flow/references/session-restore-daily-login-tracking-2026-05-08.md diff --git a/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md b/.codex/skills/genarrative-dev-stack-port-routing/SKILL.md similarity index 98% rename from .hermes/skills/genarrative-dev-stack-port-routing/SKILL.md rename to .codex/skills/genarrative-dev-stack-port-routing/SKILL.md index cde9e6977..0a0a6540c 100644 --- a/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md +++ b/.codex/skills/genarrative-dev-stack-port-routing/SKILL.md @@ -1,12 +1,9 @@ --- name: genarrative-dev-stack-port-routing -short_description: 修改 Genarrative 本地 dev 启动端口、代理目标、端口冲突处理时使用。 description: 在 Genarrative 中修改 npm run dev / dev:spacetime / dev:api-server / dev:bgfilter-worker / dev:web / dev:admin-web 的本地启动端口、端口可用性探测、端口漂移、SpacetimeDB publish server、Rust 进程环境变量、Vite 代理目标和后台 admin-web 启动串联时使用。 -version: 1.2.0 -author: Hermes Agent license: MIT metadata: - hermes: + codex: tags: [Genarrative, dev-stack, 端口探测, Vite, api-server, SpacetimeDB, npm-run-dev] related_skills: [genarrative-admin-backoffice] --- diff --git a/.codex/skills/genarrative-play-type-integration/SKILL.md b/.codex/skills/genarrative-play-type-integration/SKILL.md index 63d14982e..9ba349906 100644 --- a/.codex/skills/genarrative-play-type-integration/SKILL.md +++ b/.codex/skills/genarrative-play-type-integration/SKILL.md @@ -3,7 +3,7 @@ name: genarrative-play-type-integration description: 在 Genarrative 中新增或补齐一个创作入口/玩法类型时,按入口配置、前端分流、契约、后端接口、工作台、独立生成页、结果页、发布、统一作品详情、正式 runtime、公开 read model、基础统计与作品架/广场的顺序接入。 license: MIT metadata: - author: Hermes Agent + author: Genarrative Team version: "1.0" --- @@ -90,13 +90,14 @@ metadata: 12. **旧数据策略**:旧草稿、旧发布配置、旧分享码是迁移、降级展示、重新生成,还是明确不兼容。 - `AGENTS.md` -- `.hermes/shared-memory/` +- `docs/project-memory/shared-memory/` - `CONTEXT.md` - `docs/README.md` - `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` - 相关玩法 PRD 或设计文档 +- `.codex/skills/genarrative-play-type-integration/references/genarrative-analytics-tracking-runtime.md`(涉及正式 runtime 埋点时) -如果文档不能精确指导字段、契约、资产槽位、生成流程和恢复语义,先补文档再编码。新增长期约定时同步 `.hermes/shared-memory/`。 +如果文档不能精确指导字段、契约、资产槽位、生成流程和恢复语义,先补文档再编码。新增长期约定时同步 `docs/project-memory/shared-memory/`。 ### 2. 定玩法边界 diff --git a/.hermes/skills/genarrative-play-type-integration/references/genarrative-analytics-tracking-runtime.md b/.codex/skills/genarrative-play-type-integration/references/genarrative-analytics-tracking-runtime.md similarity index 100% rename from .hermes/skills/genarrative-play-type-integration/references/genarrative-analytics-tracking-runtime.md rename to .codex/skills/genarrative-play-type-integration/references/genarrative-analytics-tracking-runtime.md diff --git a/.hermes/skills/genarrative-profile-features/SKILL.md b/.codex/skills/genarrative-profile-features/SKILL.md similarity index 99% rename from .hermes/skills/genarrative-profile-features/SKILL.md rename to .codex/skills/genarrative-profile-features/SKILL.md index 5dd6c87b5..81de55e44 100644 --- a/.hermes/skills/genarrative-profile-features/SKILL.md +++ b/.codex/skills/genarrative-profile-features/SKILL.md @@ -1,11 +1,9 @@ --- name: genarrative-profile-features description: 在 Genarrative “我的”页签新增或修改个人中心入口、独立 profile 路由、反馈/记录/设置类页面时使用。 -version: 1.0.0 -author: Hermes Agent license: MIT metadata: - hermes: + codex: tags: [Genarrative, profile, 我的页签, 前端, 路由, 反馈] related_skills: [writing-plans, test-driven-development] --- diff --git a/.hermes/skills/genarrative-profile-features/references/profile-feedback-entry-2026-05-08.md b/.codex/skills/genarrative-profile-features/references/profile-feedback-entry-2026-05-08.md similarity index 100% rename from .hermes/skills/genarrative-profile-features/references/profile-feedback-entry-2026-05-08.md rename to .codex/skills/genarrative-profile-features/references/profile-feedback-entry-2026-05-08.md diff --git a/.hermes/skills/genarrative-profile-invite-flow/SKILL.md b/.codex/skills/genarrative-profile-invite-flow/SKILL.md similarity index 99% rename from .hermes/skills/genarrative-profile-invite-flow/SKILL.md rename to .codex/skills/genarrative-profile-invite-flow/SKILL.md index 784998a10..eb2483590 100644 --- a/.hermes/skills/genarrative-profile-invite-flow/SKILL.md +++ b/.codex/skills/genarrative-profile-invite-flow/SKILL.md @@ -1,11 +1,9 @@ --- name: genarrative-profile-invite-flow description: 在 Genarrative 中排查或修改邀请码、邀请好友、首次登录后填写邀请码、我的页签邀请码兑换链路时使用。 -version: 1.0.0 -author: Hermes Agent license: MIT metadata: - hermes: + codex: tags: [Genarrative, 邀请码, referral, auth, profile, query-params, 前端] related_skills: [] --- diff --git a/.hermes/skills/genarrative-profile-invite-flow/references/query-invite-code-flow-2026-05-07.md b/.codex/skills/genarrative-profile-invite-flow/references/query-invite-code-flow-2026-05-07.md similarity index 100% rename from .hermes/skills/genarrative-profile-invite-flow/references/query-invite-code-flow-2026-05-07.md rename to .codex/skills/genarrative-profile-invite-flow/references/query-invite-code-flow-2026-05-07.md diff --git a/.codex/skills/genarrative-spacetimedb/SKILL.md b/.codex/skills/genarrative-spacetimedb/SKILL.md new file mode 100644 index 000000000..d6a9a3b4d --- /dev/null +++ b/.codex/skills/genarrative-spacetimedb/SKILL.md @@ -0,0 +1,126 @@ +--- +name: genarrative-spacetimedb +description: Genarrative 的 SpacetimeDB 项目适配规范。用于涉及 SpacetimeDB 架构、Rust module、schema、migration、reducer、procedure、view、绑定生成、CLI、MCP、发布、调试或运行时核验的任务。 +--- + +# Genarrative SpacetimeDB 项目指导 + +本 skill 只保存 Genarrative 的项目约束和操作边界;SpacetimeDB 的通用 API、语言 SDK 和 CLI 手册由已安装的官方插件提供。项目规则覆盖插件示例中的默认值或与本仓库冲突的建议。 + +## 官方插件依赖 + +开始 SpacetimeDB 任务时,按任务范围读取官方插件 skill: + +- `spacetimedb:concepts`:核心语义、表、reducer、procedure、view、订阅和身份。 +- `spacetimedb:rust-server`:Rust module、表属性、访问器、迁移兼容性和 SDK API。 +- `spacetimedb:cli`:初始化、构建、发布、生成绑定、SQL、调用、日志和 server 管理。 +- `spacetimedb:typescript-client`:前端生成绑定、订阅和 TypeScript 客户端 SDK;其它语言客户端按需读取插件对应 skill。 +- `spacetimedb:mcp`:通过已连接的 MCP 操作运行中的数据库;没有 MCP 工具时使用 CLI 等价命令。 + +如果当前环境尚未安装插件,使用: + +```bash +codex plugin marketplace add clockworklabs/SpacetimeDB --sparse .agents --sparse codex-plugin +codex plugin add spacetimedb\@spacetimedb-plugins +``` + +插件不可用时,以当前源码、`docs/`、生成绑定和仓库脚本为准,不凭记忆发明 SpacetimeDB API。 + +## 架构边界 + +Genarrative 的唯一有效后端路线是: + +```text +server-rs + Axum + SpacetimeDB +``` + +- `module-*`:领域模型、命令、应用规则、领域事件和领域错误;不得直接依赖 Axum、SpacetimeDB table/reducer/procedure、`spacetime-client`、外部平台或文件系统。 +- `spacetime-module`:SpacetimeDB 表、reducer、procedure、view、migration、事务 adapter 和 row mapper。 +- `spacetime-client`:后端访问 SpacetimeDB 的 typed facade;其它后端 crate 不直接创建第二套访问路径。 +- `api-server`:HTTP、SSE、BFF 和外部副作用编排。 +- `platform-*`:OSS、LLM、认证、语音等外部平台能力。 +- `shared-contracts` / `packages/shared`:前后端 DTO、公开契约和无业务真相的共享 TypeScript 代码。 +- 前端只负责表现、交互、临时 UI 状态和后端结果渲染,不绕过 BFF/投影直接读取私有表或推导正式业务状态。 + +SpacetimeDB 是数据和事务层,不替代 `api-server` BFF、`spacetime-client` facade 或公开 read model。插件提供的“SpacetimeDB 可替代传统服务端”通用描述不能改变本项目边界。 + +## 语义与安全不变量 + +- Reducer 是原子事务写路径,不向调用者返回业务数据;读取通过订阅、read model、view 或 BFF。 +- Reducer 必须确定性执行:不得访问文件系统、网络、系统时钟或外部随机源;使用 `ctx.timestamp`、`ctx.rng()` / `ctx.random()` 等 SpacetimeDB 能力。 +- 授权使用上下文中的 `ctx.sender()`(或当前语言对应 API),不信任调用参数传入的身份。 +- Auto-increment ID 不是排序依据;需要顺序时使用时间戳或显式序列字段。 +- Private table 是后端事实;用户可见状态通过 BFF、投影或明确的 public table/view 暴露。公共表仍只能由 reducer/procedure 写入。 +- Procedure 在 2.8 已稳定,可使用显式事务和 `ctx.http`;Genarrative 默认仍把外部 provider 协议放在 `platform-*`,把编排放在 `api-server`,除非当前架构明确要求 module procedure。 +- Event table 必须显式订阅,按插入事件消费;不要依赖其持久化行或 `OnUpdate`。需要更新回调时使用持久表或带主键的 procedural view。 +- Standalone MCP 是 operator/developer 集成面,不是 BFF、facade 或公开 read model 的替代品。MCP/SQL/CLI 的写入都必须有明确授权;日常 smoke 优先只读。 + +## Schema 与迁移 + +修改现有 SpacetimeDB persistent table 时: + +1. 新字段只能追加到 Rust 表结构体末尾,并设置明确的 `#[default(...)]`。 +2. 删除、改名、重排、改类型或破坏性约束变更前,必须先询问用户并确认迁移计划。 +3. 同步更新 `server-rs/crates/spacetime-module/src/migration.rs`、后端架构文档中的表目录、生成绑定和相关契约/测试。 +4. 运行: + + ```bash + npm run spacetime:generate + npm run check:spacetime-schema + ``` + +Event table 的较宽松自动迁移规则不适用于 persistent table,不能借此绕过上述门禁。以当前源码和 `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 为 schema 真相。 + +## CLI、目标 server 与本地开发 + +- 优先使用仓库 wrapper:`npm run dev:spacetime`、`npm run dev:api-server`、`npm run spacetime:generate`。 +- 直接使用 CLI 时始终显式传 `--server` 或 `--server-url`;不要依赖默认云端目标或个人 CLI 默认 server。 +- 不新增 `maincloud` / `MAINCLOUD` 命令、环境变量、脚本或文档;历史残留只按历史处理。 +- 人工命令、本地联调、排障步骤和文档示例禁止使用 `spacetime --root-dir`;本地数据隔离使用项目脚本或 `--data-dir`。 +- `spacetime publish` 的 `--delete-data=always` 只在明确授权的破坏性操作中使用;schema 冲突优先按项目脚本和受控迁移流程处理。 +- 项目 SpacetimeDB crate、SDK、CLI/standalone 和生成 bindings 按 `2.8.3` 对齐;官方发行资产、Rust crates 和容器镜像使用 `v2.8.3` 版本标签,仓库额外固定 CLI commit `8e410d2842147bd8e5a32a9589cc00c19f7478e2`。升级时核对 Cargo 精确 pin、实际 CLI 和运行中服务二进制,不把本地 CLI 重装当作仓库升级。 + +本地开发默认由项目启动器管理端口;实际监听地址以 `.app/dev-stack.json` 和启动日志为准,不能从文档默认端口推断当前目标。发布后确认 api-server 使用的是同一 database、server 和 token。 + +## MCP 与运行时核验 + +如果当前会话暴露 SpacetimeDB MCP 工具,读取运行中的数据库优先使用 typed MCP:先 `list_databases` / `get_schema`,再做只读 SQL 或 `ping`;调用 reducer 或 SQL 写入前确认目标、身份和授权。没有 MCP 工具时使用显式目标的 CLI。2.8 standalone 的 MCP HTTP endpoint 是 `POST /v1/database/{name_or_identity}/mcp`,提供 `ping`、`get_schema`、`sql`、`call`;升级 smoke 在隔离数据库中只做 `initialize`、`tools/list`、`ping`、`get_schema`,除非写入明确属于任务范围。 + +排查“服务健康但业务不可用”时按顺序核对: + +1. SpacetimeDB standalone 是否运行(本地优先 `npm run dev:spacetime`,主机侧核对 systemd)。 +2. module 是否发布到 api-server 实际使用的同一个 server/database。 +3. 生成绑定是否来自当前 module。 +4. api-server 的 database、server URL 和 token 是否一致。 +5. reducer/procedure 是否真正被调用;区分超时、权限、schema 不存在和业务错误。 +6. `/healthz` / `/readyz` 通过但业务仍失败时,继续检查 API 日志和公开路由,不把健康检查当作业务成功证明。 + +主机升级需核对运行中进程而非只看 PATH: + +```bash +type -a spacetime +spacetime --version +pid="$(systemctl show spacetimedb.service -p MainPID --value)" +readlink -f "/proc/${pid}/exe" +"/proc/${pid}/exe" --version +curl -fsS http://127.0.0.1:3101/v1/ping +``` + +## 修改后的最小验证 + +按范围执行定向测试/类型检查,并至少运行: + +```bash +npm run check:encoding +git diff --check +``` + +涉及 schema 时追加 `npm run spacetime:generate` 和 `npm run check:spacetime-schema`;涉及 API 时按当前后端文档启动 `npm run dev:api-server` 并检查 `/healthz`。无法运行的验证要在交付说明中标记为未验证并说明原因。 + +## 参考入口 + +- `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` +- `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` +- `server-rs/README.md` +- `scripts/check-spacetime-schema-guard.mjs` +- `scripts/check-server-rs-ddd-boundaries.mjs` diff --git a/.codex/skills/spacetimedb-cli/SKILL.md b/.codex/skills/spacetimedb-cli/SKILL.md deleted file mode 100644 index 73dac7168..000000000 --- a/.codex/skills/spacetimedb-cli/SKILL.md +++ /dev/null @@ -1,178 +0,0 @@ ---- -name: spacetimedb-cli -description: SpacetimeDB 2.7 CLI reference for Genarrative. Use for spacetime build, publish, generate, call, sql, logs, server management, local dev, explicit server targeting, version checks, and remote runtime verification. ---- - -# SpacetimeDB CLI - -Use this skill when working with the `spacetime` CLI in Genarrative. Prefer repository scripts when they exist, and keep every operation pinned to an explicit target server or local process. - -## Genarrative Rules - -- Do not rely on the default SpacetimeDB cloud target. Pass `--server` or `--server-url` explicitly in scripts, docs, smoke tests, and manual troubleshooting. -- Do not introduce `maincloud` / `MAINCLOUD` commands, env vars, or docs. Treat old references as historical residue. -- Do not use `spacetime --root-dir` in manual commands or docs. Use project scripts, `--data-dir`, explicit `--server`, or the configured running service. -- For repository version upgrades, update `server-rs/Cargo.toml` exact pins, regenerate bindings, and verify the actual CLI/runtime version. Do not treat a local CLI reinstall as a repo upgrade. -- For host upgrades, verify the running service binary, not just shell PATH: `systemctl show ... MainPID` -> `/proc/$pid/exe --version` -> `/v1/ping`. - -## Core Commands - -```bash -# Build module -spacetime build -spacetime build --debug - -# Publish to an explicit server -spacetime publish my-database --server http://127.0.0.1:3101 --yes=migrate,break-clients - -# Destructive publish only when explicitly intended -spacetime publish my-database --server http://127.0.0.1:3101 --delete-data=always --yes=delete-data,migrate - -# Delete data only for breaking schema conflicts -spacetime publish my-database --server http://127.0.0.1:3101 --delete-data=on-conflict --yes=migrate - -# Generate bindings -spacetime generate --lang typescript|csharp|rust|unrealcpp --out-dir ./bindings --module-path ./server -``` - -## Genarrative Local Workflow - -```bash -# Prefer project wrappers -npm run dev:spacetime -npm run dev:api-server -npm run spacetime:generate - -# Query local database -spacetime sql my-db --server http://127.0.0.1:3101 "SELECT * FROM players" - -# Logs -spacetime logs my-db --server http://127.0.0.1:3101 -f -``` - -## Database Interaction - -```bash -# SQL / describe -spacetime sql my-db --server http://127.0.0.1:3101 "SELECT * FROM users" -spacetime describe my-db --server http://127.0.0.1:3101 --json -spacetime describe my-db table users --server http://127.0.0.1:3101 --json - -# Reducer/procedure calls. Arguments are positional JSON values. -spacetime call --server http://127.0.0.1:3101 my-db my_reducer '"value"' '123' - -# 2.5+ accepts hex strings for Identity arguments without full JSON tuple syntax. -spacetime call --server http://127.0.0.1:3101 my-db reducer_needing_identity 0xabc123... - -# Subscribe from CLI -spacetime subscribe my-db "SELECT * FROM users" --num-updates 10 --server http://127.0.0.1:3101 -``` - -## Standalone MCP Endpoint (2.7) - -SpacetimeDB 2.7 standalone exposes an authenticated JSON-RPC MCP endpoint at -`POST /v1/database/{name_or_identity}/mcp`. It advertises `ping`, `get_schema`, -`sql`, and `call`. The SQL and reducer tools execute with the bearer token's -identity, so keep routine smoke checks read-only. - -```bash -curl -fsS \ - -H "Authorization: Bearer ${SPACETIME_TOKEN}" \ - -H 'Content-Type: application/json' \ - --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"genarrative-smoke","version":"1.0.0"}}}' \ - http://127.0.0.1:3101/v1/database/my-db/mcp - -curl -fsS \ - -H "Authorization: Bearer ${SPACETIME_TOKEN}" \ - -H 'Content-Type: application/json' \ - --data '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ping","arguments":{"message":"genarrative"}}}' \ - http://127.0.0.1:3101/v1/database/my-db/mcp -``` - -For repository upgrade validation, also call `tools/list` and the read-only -`get_schema` tool against an isolated local database. Do not use `sql` or `call` -for writes unless that mutation is explicitly in scope. - -## Server & Auth - -```bash -spacetime server list -spacetime server add local --url http://localhost:3000 --default -spacetime server add genarrative-dev --url http://127.0.0.1:3101 -spacetime server ping genarrative-dev - -spacetime login -spacetime login --token -spacetime login show -spacetime logout -``` - -## Version & Runtime Verification - -```bash -# CLI resolution can be misleading; compare all candidates when diagnosing. -type -a spacetime -spacetime --version -spacetime version list - -# Verify a systemd service binary actually changed. -pid="$(systemctl show spacetimedb.service -p MainPID --value)" -readlink -f "/proc/${pid}/exe" -"/proc/${pid}/exe" --version -curl -fsS http://127.0.0.1:3101/v1/ping -``` - -## Flags - -| Flag | Description | -|------|-------------| -| `--server`, `-s` | Target server nickname, host, or URL | -| `--yes`, `-y` | Non-interactive prompt skipping; in 2.6+ use scoped values | -| `--delete-data`, `-c` | Publish data policy: `always`, `on-conflict`, or `never` | -| `--module-path`, `-p` | Module project path | -| `--bin-path`, `-b` | Publish/generate from compiled wasm | -| `--no-config` | Ignore `spacetime.json` | -| `--env` | Select config file layering environment | - -## Troubleshooting - -### Not Logged In - -```bash -spacetime login -``` - -### Server Not Responding - -```bash -spacetime server ping -curl -fsS http://127.0.0.1:3101/v1/ping -``` - -For local Genarrative work, start SpacetimeDB first with `npm run dev:spacetime`, then start `npm run dev:api-server`. - -### Schema Conflict - -```bash -spacetime publish my-db --server http://127.0.0.1:3101 --delete-data=on-conflict --yes=migrate -``` - -Use `--delete-data=always` only with explicit approval. - -### Version Mismatch - -```bash -rg -n 'spacetimedb' server-rs/Cargo.toml -spacetime --version -spacetime version list -pid="$(systemctl show spacetimedb.service -p MainPID --value)" -"/proc/${pid}/exe" --version -``` - -## Notes - -- Procedure calls remain stable in 2.7; module HTTP handlers/webhooks and RLS capabilities still require their documented gates. -- 2.5 fixed `publish --delete-data` config fallback; 2.6 kept that behavior and improved CLI binary distribution; 2.7 adds `spacetime sql --format json` and database `lock` / `unlock`. -- The official 2.7.0 Linux release archives and container image currently use the `v2.7.0-hotfix3` asset tag while binaries report `2.7.0`; keep the asset tag distinct from the runtime version check. -- Do not assume `spacetime version install 2.7.0` selected hotfix3: stale updater metadata can install bare-tag commit `a08663c7...`. For the current release, verify CLI commit `d220349a...` and use the official hotfix3 archive or repository provision flow when it differs. -- Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on CLI defaults. diff --git a/.codex/skills/spacetimedb-concepts/SKILL.md b/.codex/skills/spacetimedb-concepts/SKILL.md deleted file mode 100644 index e671603dd..000000000 --- a/.codex/skills/spacetimedb-concepts/SKILL.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -name: spacetimedb-concepts -description: Understand SpacetimeDB 2.7 architecture, reducer/procedure/table/view semantics, schema evolution, subscriptions, identity, and Genarrative-specific backend boundaries. Use when designing or reviewing SpacetimeDB-backed features. ---- - -# SpacetimeDB Core Concepts - -SpacetimeDB is a relational database that also executes application logic in uploaded modules. In Genarrative, it is the data and transaction layer behind `server-rs + Axum + SpacetimeDB`, not a replacement for the `api-server` BFF or external platform adapters. - -## Genarrative Boundaries - -- Domain rules live in `module-*`. -- SpacetimeDB tables, reducers, procedures, migrations, row mappers, and read models live in `spacetime-module`. -- Backend access goes through `spacetime-client` facades. -- HTTP/SSE/BFF and external orchestration stay in `api-server`. -- External side effects stay in `platform-*`. -- Frontend renders backend truth and must not bypass BFF/projections to invent formal business state. - -## Critical Rules - -1. **Reducers are transactional**: they do not return data to callers. Read through subscriptions, read models, views, or BFF endpoints. -2. **Reducers are deterministic**: no filesystem, network, wall-clock, or external RNG. Use `ctx.timestamp`, `ctx.rng()` / `ctx.random()`, and tables. -3. **Procedures are stable in 2.7**: they can use explicit transactions and outgoing HTTP via `ctx.http`. -4. **Identity comes from context**: use `ctx.sender()` or language equivalent for authorization. Never trust identity passed as an argument. -5. **Auto-increment IDs are not ordering guarantees**: gaps are normal. Use timestamps or explicit sequence columns for ordering. -6. **Schema changes need migration discipline**: existing Genarrative table fields must be appended with defaults; update migration code, table catalog, generated bindings, and run `npm run check:spacetime-schema`. - -## Tables - -- Private tables are the default; only reducers/procedures and database owners can access them. -- Public tables are exposed to clients through subscriptions. Writes still go through reducers/procedures. -- Organize data by access pattern when bandwidth or update frequency differs. -- Existing persistent tables in Genarrative are conservative: no rename, delete, reorder, or type changes without a user-approved migration plan. - -## Reducers - -Reducers are deterministic transactional functions. They are the primary client-invoked mutation path. - -- No global mutable state. -- No filesystem, network, timers, or non-deterministic RNG. -- Return `Result<(), String>` for expected sender-visible errors. -- Use `ctx.sender()` for authorization. -- Store persistent state in tables. - -## Procedures - -Procedures are stable in 2.7. They can be scheduled, can open explicit transactions with `with_tx` / `try_with_tx`, and can use outgoing HTTP (`ctx.http`). - -Genarrative default: keep external provider protocols in `platform-*` and orchestration in `api-server` unless a task explicitly moves a workflow into a module procedure. - -Module HTTP handlers/webhooks and RLS `client_visibility_filter` remain subject to their documented gates in 2.7. - -## Views - -Views expose computed read-only data. SpacetimeDB 2.7 supports primary keys on procedural views in Rust, TypeScript, C#, and C++. Clients can receive update events when subscribed to such views with primary keys. Ensure the view never returns duplicate primary keys, because that can fail view refresh and roll back the triggering transaction. - -## Event Tables - -Event tables broadcast reducer/procedure-specific facts to subscribers and must be subscribed explicitly. They are excluded from `subscribe_to_all_tables()`. - -Since 2.6, event tables support broader layout-altering automigrations, including column removal, reordering, and type changes that regular tables reject. This relaxed migration behavior is for event-only tables, not persistent tables. - -Event-table primary keys and constraints are transaction-scoped. They can reject duplicate event rows within one transaction, but event rows are not retained in client cache, so clients observe event tables through insert callbacks only. Do not design Genarrative event tables around `OnUpdate` / `on_update` / `onUpdate`; use a persistent table or a primary-keyed procedural view when update callbacks are required. - -Official 2.4.1 through 2.7 release notes document primary-key-backed update callbacks for procedural views, not event tables. - -## Subscriptions - -1. Subscribe to SQL queries or generated table/query builders. -2. Receive initial matching rows. -3. Receive updates when subscribed rows change. -4. Render from subscribed data, not reducer return values. - -Best practices: - -- Group subscriptions by lifetime. -- Subscribe to new data before unsubscribing old data during transitions. -- Avoid overlapping queries that duplicate row delivery. -- Use indexes for subscribed filters. - -## Standalone MCP - -SpacetimeDB 2.7 standalone exposes `POST /v1/database/{name_or_identity}/mcp` -using MCP JSON-RPC protocol `2025-06-18`. Its tools are `ping`, `get_schema`, -`sql`, and `call`; SQL and reducer calls run with the authenticated caller's -identity. In Genarrative this is an operator/developer integration surface, not -a replacement for `api-server` BFF routes, `spacetime-client` facades, or public -read models. Upgrade smoke should use an isolated local database and restrict -itself to `initialize`, `tools/list`, `ping`, and `get_schema` unless writes are -explicitly intended. - -## 2.2.0 to 2.7.0 Delta - -Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then: - -- **2.2.0**: v3 WebSocket transport and TS SDK default, safer production operations (`lock`/`unlock`, safer `delete`, better `publish --yes`), TS React `useProcedure`, table clearing APIs, empty-table drop automigration, primary-key migration fixes, bytes-key B-tree support, durability hardening. -- **2.3.0**: first-party Godot SDK, more WebSocket pipelining/batching, HTTP/2 backend support, Vue `useProcedure`, Unity 6 WebGL support, commitlog compression/throughput improvements, Rust `DbContext` generics, `ReducerContext::identity` deprecated in favor of `database_identity`, connection lifecycle and unsubscribe fixes. -- **2.4.0**: unstable module HTTP handlers/webhooks, faster synchronous WASM reducer runtime, commitlog resume truncation fix for silent data loss risk, better commitlog decode context, V8 heap metrics for procedure workers, JS execution-time billing regression reverted. -- **2.4.1**: Rust and TypeScript procedural views can declare primary keys, enabling `OnUpdate` events for subscribed views; fixed index schema from ST tables. -- **2.5.0**: procedures are stable, C# procedural views gain primary keys, event tables allow broader layout-altering automigrations, BTreeSet storage makes row insertion deterministic and avoids accidentally quadratic bulk insert behavior, `wasm_memory_bytes` billing metric semantics changed, template version constraints unified, `publish --delete-data` config fallback fixed, CLI `call` accepts hex Identity arguments. -- **2.6.0**: procedural-view primary keys are available across Rust, TypeScript, and C#, commitlog gains `max_segment_size` / `write_buffer_size` / `preallocate_segments`, the default write buffer increases for throughput, event-table automigrations improve, and CLI binary distribution expands. -- **2.6.1**: procedure contexts again receive the caller `Identity` and `ConnectionId`; generated TypeScript `Option` fields use optional keys; `spacetime init --template` lists available templates when no template argument is supplied. -- **2.7.0**: existing tables can add unique or primary-key constraints when current data satisfies them; standalone exposes an authenticated database MCP endpoint; Rust adds context-capability and table-accessor traits; `spacetime sql --format json` and database locking are available; view cleanup, backing-table migration, connection metrics, and memory metrics improve. Official current release assets use the `v2.7.0-hotfix3` tag while binaries report `2.7.0`. - -## Debugging Checklist - -1. Is the Genarrative SpacetimeDB server running? Use `npm run dev:spacetime` locally or host-local `systemctl`. -2. Is the module published to the same server the API uses? -3. Are generated bindings current? Use `npm run spacetime:generate`. -4. Is `api-server` using the same database and token? -5. Is the reducer/procedure actually called? -6. Did `/healthz` / `/readyz` pass while business SpacetimeDB calls still timeout? Inspect API logs and public route behavior. - -## Editing Behavior - -- Make the smallest change necessary. -- Do not invent SpacetimeDB APIs; verify against current docs, generated bindings, or source. -- For Genarrative schema edits, update migration code, table catalog/docs, generated bindings, and relevant tests. -- After schema edits, run `npm run spacetime:generate` and `npm run check:spacetime-schema`. diff --git a/.codex/skills/spacetimedb-rust/SKILL.md b/.codex/skills/spacetimedb-rust/SKILL.md deleted file mode 100644 index ef0a239d6..000000000 --- a/.codex/skills/spacetimedb-rust/SKILL.md +++ /dev/null @@ -1,280 +0,0 @@ ---- -name: spacetimedb-rust -description: Develop SpacetimeDB 2.7 server modules in Rust for Genarrative. Use when writing or reviewing tables, reducers, procedures, views, migrations, row mappers, schema changes, and module logic. ---- - -# SpacetimeDB Rust Module Development - -Use this skill for Rust code in `server-rs/crates/spacetime-module` and related Genarrative schema/migration work. - -## Genarrative Rules - -- Keep domain rules in `module-*`; keep SpacetimeDB tables, reducers, procedures, views, mappers, and transaction adapters in `spacetime-module`. -- Existing table fields must be appended at the end with explicit defaults. Do not rename, remove, reorder, or change field types without a user-confirmed migration plan. -- After schema changes, update `migration.rs`, table catalog/docs, generated bindings, and run `npm run spacetime:generate` plus `npm run check:spacetime-schema`. -- Private tables are backend facts. Expose user-visible state through BFF endpoints/read models rather than direct client SQL. - -## Hallucinated APIs: Do Not Use - -```rust -#[derive(Table)] // Tables use #[table], not derive -#[derive(Reducer)] // Reducers use #[reducer], not derive -#[derive(SpacetimeType)] // Do not derive this on #[table] structs - -pub fn reducer(ctx: &mut ReducerContext) {} // Use &ReducerContext - -ctx.db.player // Use ctx.db.player() -ctx.db.player.find(id) // Use ctx.db.player().id().find(&id) -ctx.sender // Use ctx.sender() -ctx.db.user().name().update(..) // Update by primary key only - -spacetimedb = { version = "...", features = ["unstable"] } // Not needed for procedures since 2.5 -``` - -## Required Patterns - -```rust -use spacetimedb::{reducer, table, Identity, ReducerContext, Table, Timestamp}; -use spacetimedb::SpacetimeType; // Custom types only, not tables - -#[table(accessor = player, public)] -pub struct Player { - #[primary_key] - #[auto_inc] - pub id: u64, - pub owner: Identity, - pub name: String, - pub created_at: Timestamp, -} - -#[reducer] -pub fn create_player(ctx: &ReducerContext, name: String) -> Result<(), String> { - if name.trim().is_empty() { - return Err("name required".to_string()); - } - ctx.db.player().try_insert(Player { - id: 0, - owner: ctx.sender(), - name, - created_at: ctx.timestamp, - })?; - Ok(()) -} -``` - -Hard requirements: - -- Import `Table` for table operations. -- Use `accessor = identifier`, not string literals. -- Use `ctx.sender()` for authorization. -- Use `ctx.rng()` / `ctx.random()` / `ctx.new_uuid_*()` for deterministic randomness and UUIDs. -- Use `Result<(), String>` for expected sender errors; avoid panics except impossible states. -- Use `try_insert()` in `Result` reducers when constraint violations should be reported cleanly. - -## Tables - -```rust -#[spacetimedb::table(accessor = game_tick_schedule, scheduled(game_tick))] -pub struct GameTickSchedule { - #[primary_key] - #[auto_inc] - pub scheduled_id: u64, - pub scheduled_at: ScheduleAt, -} -``` - -Table attributes: - -| Attribute | Description | -|-----------|-------------| -| `accessor = identifier` | API name used in `ctx.db.{accessor}()` | -| `public` | Visible to clients via subscriptions | -| `event` | Transient event table | -| `scheduled(function_name)` | Schedule table that triggers a reducer/procedure | -| `index(accessor = idx, btree(columns = [a, b]))` | Multi-column index | - -Column attributes: - -| Attribute | Description | -|-----------|-------------| -| `#[primary_key]` | One primary key per table | -| `#[auto_inc]` | Auto-generates integer values when inserting `0` | -| `#[unique]` | Unique constraint and `find()` accessor | -| `#[index(btree)]` | B-tree index and `filter()` accessor | -| `#[default(...)]` | Required for new fields on existing Genarrative tables | - -## Genarrative Schema Change Pattern - -```rust -#[spacetimedb::table(accessor = creation_entry_config, public)] -pub struct CreationEntryConfig { - #[primary_key] - pub id: u64, - pub existing_field: String, - - // Append new fields at the end and provide a default. - #[default(false)] - pub new_flag: bool, -} -``` - -Then update `migration.rs`, table catalog/docs, generated bindings, and run: - -```bash -npm run spacetime:generate -npm run check:spacetime-schema -``` - -## Table Operations - -```rust -let row = ctx.db.player().insert(Player { id: 0, owner, name, created_at }); -ctx.db.player().try_insert(row)?; - -let by_id = ctx.db.player().id().find(&123u64); -for player in ctx.db.player().owner().filter(&ctx.sender()) {} -for player in ctx.db.player().level().filter(&(18u32..=65u32)) {} -for player in ctx.db.player().iter() {} -let count = ctx.db.player().count(); - -if let Some(player) = ctx.db.player().id().find(&id) { - ctx.db.player().id().update(Player { name: new_name, ..player }); -} - -ctx.db.player().id().delete(&id); -``` - -For delete/update based on non-PK filters, collect keys first to avoid iterator invalidation. - -## Indexes - -```rust -#[spacetimedb::table( - accessor = score, - public, - index(accessor = by_player_level, btree(columns = [player_id, level])) -)] -pub struct Score { - pub player_id: u32, - pub level: u32, - pub points: i64, -} - -for row in ctx.db.score().by_player_level().filter(&(42,)) {} -for row in ctx.db.score().by_player_level().filter(&(42, 5)) {} -``` - -## Event Tables - -```rust -#[table(accessor = damage_event, public, event)] -pub struct DamageEvent { - pub target: Identity, - pub amount: u32, -} - -#[reducer] -fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) { - ctx.db.damage_event().insert(DamageEvent { target, amount }); -} -``` - -Event tables must be subscribed explicitly and are excluded from `subscribe_to_all_tables()`. - -Since 2.6, event tables support broader layout-altering automigrations than regular tables, including column removal, reordering, and type changes. This relaxed migration policy does not apply to persistent tables. - -Event-table primary keys and constraints are enforced only within the current transaction. They do not make event rows persistent, and client SDKs expose event tables as insert-only event streams. Do not rely on `OnUpdate` / `on_update` / `onUpdate` for event tables; use a persistent table or a primary-keyed procedural view when update callbacks are required. - -Official 2.4.1 through 2.7 release notes tie primary-key-backed update callbacks to procedural views, not event tables. - -## Views - -```rust -#[spacetimedb::view(accessor = my_players, public, primary_key = id)] -pub fn my_players(ctx: &spacetimedb::ViewContext) -> Vec { - ctx.db.player().owner().filter(&ctx.sender()).collect() -} -``` - -Rust and TypeScript gained primary key support for procedural views in 2.4.1. With primary keys, clients can receive update events when subscribed to such views. Avoid duplicate primary keys in view results. - -## Lifecycle & Scheduled Reducers - -```rust -#[spacetimedb::reducer(init)] -pub fn init(ctx: &ReducerContext) -> Result<(), String> { Ok(()) } - -#[spacetimedb::reducer(client_connected)] -pub fn on_connect(ctx: &ReducerContext) -> Result<(), String> { Ok(()) } - -#[spacetimedb::reducer(client_disconnected)] -pub fn on_disconnect(ctx: &ReducerContext) -> Result<(), String> { Ok(()) } - -use spacetimedb::{ScheduleAt, TimeDuration}; - -ctx.db.game_tick_schedule().insert(GameTickSchedule { - scheduled_id: 0, - scheduled_at: ScheduleAt::Interval(std::time::Duration::from_millis(100).into()), -}); - -let run_at = ctx.timestamp + std::time::Duration::from_secs(60); -ctx.db.game_tick_schedule().insert(GameTickSchedule { - scheduled_id: 0, - scheduled_at: ScheduleAt::Time(run_at), -}); -``` - -For scheduled reducers, check `ctx.sender_auth().is_internal()` when the reducer should only be system-triggered. - -## Procedures - -Procedures remain stable in 2.7 and no longer require the `unstable` feature. - -```rust -use spacetimedb::{procedure, ProcedureContext}; - -#[procedure] -fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> { - let body = ctx.http.get(url).send()?.text()?; - ctx.try_with_tx(|tx| { - tx.db.external_data().insert(ExternalData { id: 0, content: body }); - Ok(()) - })?; - Ok(()) -} -``` - -| Reducers | Procedures | -|----------|------------| -| `&ReducerContext` | `&mut ProcedureContext` | -| Direct `ctx.db` access | Use `with_tx()` / `try_with_tx()` | -| No HTTP/network | Outgoing HTTP via `ctx.http` | -| Deterministic transaction path | Side-effect-capable workflow path | - -In Genarrative, keep external provider protocols in `platform-*` by default unless the architecture explicitly moves that workflow into the module. - -## Identity & Auth - -```rust -fn require_owner(ctx: &ReducerContext, owner: &Identity) -> Result<(), String> { - if ctx.sender() != *owner { - return Err("Not authorized".to_string()); - } - Ok(()) -} -``` - -`ReducerContext::identity` is deprecated since 2.3; use the current database/module identity API when needed, and use `ctx.sender()` for caller identity. - -## Commands - -```bash -spacetime build -spacetime publish my_database --server http://127.0.0.1:3101 --module-path . --yes=migrate -spacetime publish my_database --server http://127.0.0.1:3101 --delete-data=on-conflict --module-path . --yes=migrate -spacetime logs my_database --server http://127.0.0.1:3101 -spacetime call --server http://127.0.0.1:3101 my_database create_player '"Alice"' -spacetime sql my_database --server http://127.0.0.1:3101 "SELECT * FROM player" -npm run spacetime:generate -npm run check:spacetime-schema -``` diff --git a/.hermes/README.md b/.hermes/README.md deleted file mode 100644 index e8b760a11..000000000 --- a/.hermes/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# Genarrative Hermes 工具目录 - -本目录只保留 Hermes 专用的仓库级工具资源,例如 Hermes skills、plugins 和启用说明。项目知识本体、长期记忆、计划和 TODO 不再放在 `.hermes/`,统一迁移到 `docs/project-memory/`。 - -## 使用原则 - -- `.hermes/` 中只保存 Hermes 工具运行或加载所需内容。 -- 项目长期知识、架构约定、排障经验、协作规则、计划和 TODO 统一放在 `docs/project-memory/`。 -- 不提交个人配置、API Key、会话转录、模型密钥、本地路径密钥等敏感内容。 -- 个人 Hermes 的 `~/.hermes/config.yaml`、`~/.hermes/.env`、`~/.hermes/sessions/` 不应复制到本仓库。 -- 后续新增的 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`,便于团队跨目录检索。 -- 若 `.hermes/` 中的工具说明与代码或 `docs/` 冲突,以当前代码和最新 `docs/` 为准。 - -## 目录结构 - -```text -.hermes/ -├─ README.md # Hermes 工具目录说明 -├─ skills/ # 仓库级 Hermes skills -└─ plugins/ # 仓库级 Hermes plugins(需显式启用项目 plugin) -``` - -## 仓库级 Plugins - -本仓库可共享的 Hermes plugin 放在 `.hermes/plugins//`。当前已包含: - -- `.hermes/plugins/game-studio/`:浏览器游戏设计、原型、2D/3D 技术栈、素材管线与 playtest 相关工作流。 - -Hermes 的项目级 plugin 默认不会自动加载。团队成员拉取仓库后,如需使用本仓库内 plugin,请在仓库根目录启动 Hermes 前设置: - -```bash -export HERMES_ENABLE_PROJECT_PLUGINS=1 -``` - -然后确认当前 Hermes 配置的 `plugins.enabled` 中包含 `game-studio`。如果成员本机尚未启用过该 plugin,当前 Hermes 的 `hermes plugins enable` 只识别用户级或内置 plugin,可能不会识别项目级 plugin;可用以下命令写入个人配置: - -```bash -python - <<'PY' -from hermes_cli.config import load_config, save_config -config = load_config() -plugins = config.setdefault('plugins', {}) -enabled = set(plugins.get('enabled') or []) -disabled = set(plugins.get('disabled') or []) -enabled.add('game-studio') -disabled.discard('game-studio') -plugins['enabled'] = sorted(enabled) -plugins['disabled'] = sorted(disabled) -save_config(config) -PY -``` - -启用后重新进入一个新 Hermes 会话。`hermes plugins list` 当前主要展示内置和用户级 plugin,未必列出项目级 plugin;如需验证项目级扫描,可在仓库根目录运行: - -```bash -HERMES_ENABLE_PROJECT_PLUGINS=1 HERMES_PLUGINS_DEBUG=1 hermes chat -q "请读取 game-studio:game-studio skill 并概括它的用途" -``` - -该 plugin 注册的是带命名空间的 plugin skills,可用类似 `game-studio:phaser-2d-game` 的名称显式加载。 - -## 推荐给 Hermes 的启动提示 - -在本仓库中开始复杂任务时,可以先对 Hermes 说: - -```text -请先读取 AGENTS.md 以及 docs/project-memory/shared-memory/ 下与本任务相关的团队共享记忆,再开始分析。若任务完成后产生稳定项目知识,请更新 docs/project-memory/shared-memory/ 对应文件。 -``` diff --git a/.hermes/skills/README.md b/.hermes/skills/README.md deleted file mode 100644 index 3464aa0e3..000000000 --- a/.hermes/skills/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# 仓库级 Hermes Skills - -本目录预留给未来可共享的仓库级 Hermes skills。 - -## 什么时候沉淀为 Skill - -当某个流程满足以下条件之一时,可以考虑从普通 Markdown 升级为 skill: - -- 需要反复执行,且步骤稳定。 -- 涉及多个目录、命令或验证步骤。 -- 曾经踩过坑,需要明确规避步骤。 -- 新成员容易做错。 -- Hermes 在执行时需要强制加载专门知识。 - -## 建议结构 - -```text -.hermes/skills/ -└─ skill-name/ - └─ SKILL.md -``` - -## 注意 - -- 不要把 API Key、Token、账号密码写入 skill。 -- 如果 skill 与 `AGENTS.md` 或 `docs/` 冲突,先更新冲突来源再使用。 -- Skill 应包含触发条件、步骤、坑点和验证方式。 diff --git a/.hermes/skills/genarrative-play-type-integration/SKILL.md b/.hermes/skills/genarrative-play-type-integration/SKILL.md deleted file mode 100644 index f34f15583..000000000 --- a/.hermes/skills/genarrative-play-type-integration/SKILL.md +++ /dev/null @@ -1,253 +0,0 @@ ---- -name: genarrative-play-type-integration -description: 在 Genarrative 新增、开放或重构玩法创作工具时,按平台级强约束 SOP 接入入口配置、表单/图片输入创作工作台、单图资产槽位、系列素材图集生成、独立契约、后端 DDD、结果页、运行态、作品架、广场与验证;用于避免复制既有玩法、默认对话式 Agent、页面内手写图片输入或复用玩法专属素材模型。 ---- - -# Genarrative 新增玩法创作工具平台 SOP - -把新增玩法当成平台能力接入,不把任何既有玩法当作默认模板。先确定通用模式和契约,再写具体玩法代码。 - -## 硬性禁区 - -- 不恢复前端硬编码入口配置;创作入口事实源必须来自 SpacetimeDB 和 `/api/creation-entry/config`。 -- 不把聊天输入区、流式消息或轻输入 Agent 作为新增玩法默认工作台。 -- 不在新页面内手写图片上传、参考图、AI 重绘、历史图选择、预览或删除确认逻辑。 -- 不把通用系列素材建模成任一玩法专属 DTO;玩法只能追加自己的运行态字段。 -- 不让前端承接正式业务真相;发布、试玩、通关、失败、计分、资产持久化和作品状态以后端投影为准。 -- 不新建平行入口系统、平行作品架或平行公开列表;优先扩展现有平台壳、现有阶段和现有聚合。 -- 不在 UI 面板内默认写功能说明、规则说明或开发解释文案。 - -## 接入前输入 - -开始编码前,PRD 或当前玩法文档必须已经明确: - -- `playId`、对外名称、工程域名、入口 `visible/open` 状态。 -- 创作链路:入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态。 -- 表单字段:字段名、默认值、校验、后端落库位置、生成提示词来源。 -- 单图资产槽位:`slotId`、`slotType`、`slotName`、提示词来源、读取字段、写回字段、是否允许历史图和 AI 重绘。 -- 系列素材槽位:`batchId` 语义、`sheetSpec`、`slotSpecs`、切图规则、透明化规则、失败回写、局部重生成策略。 -- API 命名空间:`/api/creation//sessions`、`actions`、`works`、`runtime`。 -- 草稿恢复、生成中恢复、失败重试、登录切换、发布后回读和移动端行为。 -- 验证命令和例外声明;没有例外时写明“无创作工具模式例外”。 - -## 默认模式 - -新增玩法默认采用表单/图片输入创作工作台: - -```text -创作入口 -> 表单/图片输入工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态 -``` - -工作台只提交结构化表单、图片槽位和配置 payload。确需自然语言对话时,先走“例外流程”,不能把聊天区直接加进默认工作台。 - -## SOP - -### 1. 文档和领域词先行 - -先读: - -- `AGENTS.md` -- `docs/project-memory/shared-memory/` -- `CONTEXT.md` -- `docs/README.md` -- `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` -- 相关玩法 PRD 或设计文档 - -如果文档不能精确指导字段、契约、资产槽位、生成流程和恢复语义,先补文档再编码。新增长期约定时同步 `docs/project-memory/shared-memory/`。 - -### 2. 定玩法边界 - -固定 `playId`、对外名称、工程域、入口状态、是否支持结果页、试玩、发布、作品架、广场、分享和 runtime。不要先用临时 ID 接线后再批量改名。 - -### 3. 接入口配置 - -入口配置事实源是 SpacetimeDB `creation_entry_type_config`。后台通过 `/admin/api/creation-entry/config` 管理,前台通过 `/api/creation-entry/config` 读取。 - -前端只允许在展示层派生: - -- 可见入口卡片。 -- 锁定或开放状态。 -- 排序、图标、短标题等展示信息。 - -`api-server` 路由熔断必须使用同一份入口配置。禁止新增或恢复前端本地默认入口配置作为事实源。 - -### 4. 前端阶段 - -按需要扩展 `SelectionStage`: - -- `-workspace` -- `-generating` -- `-result` -- `-runtime` -- `-gallery-detail` - -阶段名可以按玩法命名,UI 形态必须仍是表单/图片创作工作台。进入工作台时只初始化结构化草稿状态,不启动默认聊天会话。 - -### 5. 工作台实现 - -工作台必须满足: - -- 使用表单控件、图片槽位、风格选项、难度选项、开关和提交按钮组织输入。 -- 单图槽位统一使用 `CreativeImageInputPanel`。 -- 组件缺少能力时先扩展 `CreativeImageInputPanel` 的受控 props,不在玩法页面复制上传、参考图、AI 重绘、历史图、预览或删除确认。 -- 主图读取、裁剪、历史素材弹层、计费确认、自动保存和后端请求由外层页面持有;通用面板只表达输入 UI 和短生命周期 UI 状态。 -- 提交 payload 必须是表单字段与图片槽位结构,不是用户消息文本。 - -### 6. 单图资产槽位 - -角色形象、UI 背景、容器、封面、分享图、图标等单张图都按单图资产槽位处理。 - -统一约定: - -- 槽位用 `slotId` 稳定标识,`slotType` 表达用途,`slotName` 用于 UI 标签。 -- 上传图、参考图、AI 重绘、历史图选择和删除确认都通过 `CreativeImageInputPanel` 入口表达。 -- 后端写回 `imageSrc`、`imageObjectKey`、`assetObjectId` 中可用字段;前端展示前通过平台资产读取能力换签。 -- 单个槽位重生成只禁用该槽位动作,不阻塞结果页其它槽位、系列素材槽位或导航。 - -### 7. 系列素材图集生成 - -地块、物品、障碍、装饰、UI 部件等一组同类素材都走通用系列素材图集生成流程: - -```text -批量规划 -> sheet 生图 -> 后端切图 -> 去背景/透明化 -> PNG 输出 -> OSS 持久化 -> 状态回写 -> 局部重生成 -``` - -玩法只提供: - -- `sheetSpec`:画布比例、行列、单格尺寸、输出格式、背景处理策略。 -- `slotSpecs`:每个素材槽位的 `slotId`、`slotType`、`slotName`、提示词、sheet 单元格映射。 -- 玩法字段映射:把通用素材结果映射回玩法自己的 draft/profile/runtime 字段。 - -通用系列素材结果建议字段: - -- `batchId` -- `slotId` -- `slotType` -- `slotName` -- `prompt` -- `imageSrc` -- `imageObjectKey` -- `assetObjectId` -- `sourceSheetCell` -- `status` -- `error` - -玩法可追加运行态字段,例如半径、宽度、视图索引或碰撞参数,但不能依赖任何玩法专属字段作为平台通用模型。新增玩法 compile action 内部调用通用系列素材服务;如果通用服务还缺能力,先补通用服务再接玩法。 - -### 8. 契约与 API - -前后端必须同步补契约: - -- `packages/shared/src/contracts/` -- `server-rs/crates/shared-contracts/src/` - -玩法 API 保留独立命名空间: - -- `POST /api/creation//sessions` -- `GET /api/creation//sessions/{sessionId}` -- `POST /api/creation//sessions/{sessionId}/actions` -- `/api/creation//works` -- `/api/creation//runtime` - -契约需要区分: - -- 工作台输入。 -- 草稿 snapshot。 -- 单图资产槽位。 -- 系列素材批次与槽位。 -- 结果页操作。 -- 发布作品摘要。 -- runtime snapshot。 - -### 9. 后端分层 - -按 DDD 边界落地: - -- `module-`:纯领域规则、状态机、draft/runtime 校验。 -- `shared-contracts`:前后端 DTO。 -- `spacetime-module`:表、reducer、procedure、事务编排、migration。 -- `spacetime-client`:typed facade 和 row mapper。 -- `api-server`:Axum 路由、鉴权、BFF、SSE、生成编排。 -- `platform-*`:LLM、图片生成、OSS、认证等外部副作用。 - -涉及 SpacetimeDB schema 时同步 `migration.rs`、表目录和绑定,并运行 `npm run check:spacetime-schema`。 - -### 10. 结果页 - -结果页至少支持: - -- 展示草稿和生成状态。 -- 返回工作台编辑。 -- 单图槽位重生成。 -- 系列素材追加、替换、局部重生成。 -- 发布。 -- 试玩。 -- 错误展示和失败重试。 - -单图槽位和系列素材槽位的生成状态互不阻塞。已有可查看结果时,局部重生成不能把作品架草稿重新变成不可打开的全局生成中。 - -### 11. 运行态、作品架和广场 - -需要试玩或发布时补齐: - -- runtime start/action/finish API。 -- 作品保存、发布、删除、回读。 -- 作品架摘要。 -- 公开列表、详情、分享码。 -- 公开列表优先消费后端投影或 BFF 缓存,不让前端直接拼源表事实。 - -运行态可以做低延迟表现,但正式胜负、分数、奖励、排行榜和发布状态以后端裁决为准。 - -### 12. 恢复与登录态 - -必须处理: - -- 刷新恢复生成中草稿。 -- 生成页计时从后端摘要时间恢复。 -- 失败后回读 session/work detail 再决定是否展示失败。 -- 退出登录清空私有玩法状态。 -- 私有生成图展示前换签。 -- result/runtime 缺必要 draft 时回到可恢复入口,不停在空白页。 - -### 13. 例外流程 - -任何非表单/图片工作台、对话式 Agent、独立创作系统或特殊资产模型都必须先更新 PRD 和平台文档。例外声明至少写清: - -- 为什么默认表单/图片工作台不能满足。 -- 例外影响哪些输入、契约、后端流程和测试。 -- 如何保留单图资产槽位和系列素材槽位的通用能力。 -- 如何回退到平台默认链路。 - -没有文档例外,不进入编码。 - -## PRD 检查块 - -在新增玩法 PRD 中保留这一段: - -```md -## 创作工具平台接入声明 - -- 工作台模式:表单/图片输入创作工作台 -- 创作链路:入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态 -- 单图资产槽位: - - slotId / slotType / slotName / 提示词来源 / 写回字段 / 是否允许历史图 / 是否允许 AI 重绘 -- 系列素材槽位: - - batchId / sheetSpec / slotSpecs / 切图规则 / 透明化规则 / 失败回写 / 局部重生成 -- API 命名空间:/api/creation//... -- 业务真相:后端裁决字段和前端表现字段边界 -- 创作工具模式例外:无;如有,先写明例外原因和回退方式 -- 验证命令: -``` - -## 验证门禁 - -按改动范围运行: - -- `npm run check:encoding` -- `npm run typecheck` -- 前端工作台测试:确认没有聊天式 Agent 输入,提交的是表单/图片 payload。 -- `CreativeImageInputPanel` 测试:覆盖多玩法标签、上传、AI 重绘、参考图上限、历史图入口和删除确认。 -- 系列素材测试:覆盖 sheet layout、切图、透明化、OSS 持久化、追加、替换、局部重生成和失败回写。 -- 结果页测试:覆盖单图槽位重生成和系列素材槽位重生成互不阻塞。 -- 后端定向测试:覆盖 compile action、资产持久化、失败回写、发布和 runtime start。 -- 涉及 SpacetimeDB schema 时运行 `npm run check:spacetime-schema`。 diff --git a/AGENTS.md b/AGENTS.md index f7969ee42..4ba512832 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,11 +4,12 @@ - 本文件只保留 Agent 进入仓库后必须立即遵守的最高优先级规则;完整执行细则见 [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。 - 团队级长期项目记忆位于 [`docs/project-memory/`](docs/project-memory/),供 3 名开发人员和各自本地 Agent 通过 Git 同步。 -- [`.hermes/`](.hermes/) 只保存 Hermes 专用仓库级工具资源,例如 skills、plugins 和启用说明;长期项目知识不要写入 `.hermes/`。 +- [`.codex/`](.codex/) 只保存仓库级 Codex 工具资源,例如 skills、plugins、hooks 和配置模板;长期项目知识不要写入 `.codex/`。 - 若 `docs/project-memory/shared-memory/` 与当前代码或最新 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。 ## 开始任务前 +- 先写清一句话交付结果、验收判据和不做项,再按“必须项 / 风险项 / 可选项”排序;优先完成修改、定向验证和边界检查组成的最小闭环。设置时间盒和检查点,新增发现只有在影响交付判据时才扩大范围,否则记录为后续事项;工具探测、历史整理或验证便利不能自行改变任务目标。 - 简单自包含任务可以直接执行;复杂开发、跨模块修改、后端 / UI / 文档体系调整前,按顺序读取: 1. 本文件。 2. [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。 @@ -19,7 +20,7 @@ ## 绝对约束 -- 禁止提交个人 `~/.hermes` 配置、`.env`、API Key、Token、Cookie、会话记录、认证文件、本地私密路径、构建产物、日志、缓存和数据库 dump。 +- 禁止提交个人 `~/.codex` 配置、`.env`、API Key、Token、Cookie、会话记录、认证文件、本地私密路径、构建产物、日志、缓存和数据库 dump。 - 不要在 `.gitignore` 中新增 `.env.local`。 - 不要擅自把现有中文文案、注释、剧情或文档改写成英文;看到中文乱码时先确认真实编码,不要沿用乱码或用英文替换。 - 修改包含中文的文件时优先局部补丁,避免整文件重写;修改后优先运行仓库编码检查。 @@ -38,11 +39,11 @@ ## 任务路由 - Issue 使用自托管 Gitea;优先用 Gitea UI/API 或 `tea` CLI,不使用 GitHub `gh` 或 GitLab `glab`,除非仓库已迁移。默认 triage 标签:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。 -- 需要仓库级 Hermes skills/plugins 时,再读取 [`.hermes/README.md`](.hermes/README.md)。 +- 需要仓库级 Codex skills/plugins 时,再读取 [`.codex/README.md`](.codex/README.md)。 - 涉及 AI 游戏创作独立 App、多智能体 Runtime、本地项目产物或本地 HTTP 预览时,先读取 [`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`](docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。 - 新增、补齐、迁移或重构玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model 前,必须读取并按 [`genarrative-play-type-integration`](.codex/skills/genarrative-play-type-integration/SKILL.md) 执行。 -- 涉及 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` / `npm run dev:web` / `npm run dev:admin-web` 的端口探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标或后台 dev 端口时,按 [`.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md`](.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md) 执行。 -- 涉及 SpacetimeDB 的设计、实现、脚本、调试、发布、绑定生成、schema、reducer、procedure、view 或 Rust API 时,必须读取并按 [`spacetimedb-cli`](.codex/skills/spacetimedb-cli/SKILL.md)、[`spacetimedb-rust`](.codex/skills/spacetimedb-rust/SKILL.md)、[`spacetimedb-concepts`](.codex/skills/spacetimedb-concepts/SKILL.md) 中相关 skill 执行。 +- 涉及 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` / `npm run dev:web` / `npm run dev:admin-web` 的端口探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标或后台 dev 端口时,按 [`.codex/skills/genarrative-dev-stack-port-routing/SKILL.md`](.codex/skills/genarrative-dev-stack-port-routing/SKILL.md) 执行。 +- 涉及 SpacetimeDB 的设计、实现、脚本、调试、发布、绑定生成、schema、reducer、procedure、view 或 API 时,必须先读取并按 [`genarrative-spacetimedb`](.codex/skills/genarrative-spacetimedb/SKILL.md) 执行。 ## 后端红线 diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs index 97d19625f..e170b07c6 100644 --- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -143,7 +143,12 @@ export const usage = `用法: --plan 走「做方案」立项策划入口,不做游戏,不做产物验收和试玩 --timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,--plan 默认 6 分钟,手工模式默认不限时 --dry-run 只检查目录发现和项目准备,不启动 LLM - -h, --help 显示帮助`; + -h, --help 显示帮助 + +环境变量: + AGC_PLAN_GDD_DECISION 审批卡自动应答动作,默认 approve;revise/reject 必须 + 同时用 AGC_PLAN_GDD_COMMENT 给出真实修改意见 + AGC_PLAN_GDD_COMMENT revise/reject 的意见原文`; function readOptionValue(args, index, option) { const value = args[index + 1]?.trim(); @@ -911,12 +916,15 @@ export function parseSettledSwarmTurnReport(output) { async function runCapturedCargo( cliArguments, setActiveChild, - { timeoutMs = null, label = 'Cargo 子命令' } = {}, + { timeoutMs = null, label = 'Cargo 子命令', stdin = null } = {}, ) { const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), { - stdio: ['ignore', 'pipe', 'pipe'], + stdio: [stdin === null ? 'ignore' : 'pipe', 'pipe', 'pipe'], }); setActiveChild(child); + if (stdin !== null) { + child.stdin.end(stdin); + } let stdout = ''; let stderr = ''; child.stdout.setEncoding('utf8'); @@ -1007,7 +1015,9 @@ async function runTaskCargo( let planGddApprovalPromise = null; const startPlanGddApproval = () => { planGddApprovalStarted = true; - console.log('[自动审批] 检测到 Fast GDD 审批位,正在提交 approve'); + console.log( + `[自动审批] 检测到 Fast GDD 审批位,正在提交 ${resolvePlanGddAutoDecision().action}`, + ); planGddApprovalPromise = onPlanGddApprovalWait() .then((value) => { planGddApproval = value; @@ -1842,13 +1852,30 @@ export function parsePlanGddDecisionOutput(output) { ); } -// 审批卡是这条链路唯一的人类判据,所以自动应答只投 approve,且只在投影确实有一张 -// 待决定审批时出手。revise/reject 需要一段真实的修改意见,让机器编一段等于把判据 -// 换成噪声;要跑那两条分支就手工调 --plan-gdd-decide。 +// 审批卡是这条链路唯一的人类判据,所以自动应答默认只投 approve,且只在投影确实有 +// 一张待决定审批时出手。revise/reject 需要一段真实的修改意见,让机器编一段等于把 +// 判据换成噪声——所以那两条分支只在跑的人自己用 AGC_PLAN_GDD_COMMENT 给出意见时 +// 才走。手工调 --plan-gdd-decide 也能达到同样效果,但那要求 plan 根 run 仍然活着, +// 而它恰好是本进程持有的 CLI 子进程。 export function planGddAutoApprovalIsPending(state) { return Boolean(state?.pendingApproval); } +export function resolvePlanGddAutoDecision(env = process.env) { + const action = (env.AGC_PLAN_GDD_DECISION ?? 'approve').trim(); + if (!['approve', 'revise', 'reject'].includes(action)) { + throw new Error('AGC_PLAN_GDD_DECISION 只能是 approve / revise / reject'); + } + const comment = (env.AGC_PLAN_GDD_COMMENT ?? '').trim(); + if (action === 'approve') return { action, comment: null }; + if (!comment) { + throw new Error( + `${action} 必须同时设 AGC_PLAN_GDD_COMMENT 提供真实修改意见`, + ); + } + return { action, comment }; +} + async function settlePlanGddApproval( projectPath, runtimeConfigPath, @@ -1875,18 +1902,21 @@ async function settlePlanGddApproval( if (!planGddAutoApprovalIsPending(before)) { return { decided: false, state: before }; } + const { action, comment } = resolvePlanGddAutoDecision(); const decision = await runCapturedCargo( [ '--config-dir', runtimeConfigPath, '--plan-gdd-decide', projectPath, - 'approve', + action, + ...(comment === null ? [] : ['--stdin']), ], setActiveChild, { timeoutMs: planGddApprovalTimeoutMs, label: 'Fast GDD 审批决定', + stdin: comment, }, ); if (decision.code !== 0 || decision.signal) { @@ -1926,7 +1956,7 @@ async function reportPlanGddApproval(approval) { return; } console.log( - ` [已批准] outcome=${approval.receipt.outcome} v${approval.receipt.decisionRef.version} 投影状态=${state.state}`, + ` [已决定 ${approval.receipt.decisionRef.action}] outcome=${approval.receipt.outcome} v${approval.receipt.decisionRef.version} 投影状态=${state.state}`, ); if (approval.recovered) { console.log( diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md index feef2a6a2..9901e42d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md @@ -5,11 +5,11 @@ 3. 等待子 Agent 期间不得调用 `respond_to_user`。Runtime 会通过 delegate 完成屏障保持同一父 run,回执到达后再继续。 4. 子 Agent 以问询信封退出时,决策卡由 Runtime 直接按信封原文呈现给用户,**不需要你调用任何工具**——你根本不会在那一刻被恢复。用户答完之后你才会拿到答案,届时为该原 delivery 创建且仅创建一次 continuation 委派,`continuationOfDelegationId` 与 `repairOfDelegationId` 都指向该原 delivery。`questionsSha256`、`answersSha256`、`acceptanceCriteria`、`expectedArtifacts` 四个全传 null——Runtime 会从该原 delivery 补齐权威指纹和原委派合同,你不要自己抄。子 Agent 在 continuation 里**再次**以信封退出时,对那条新 delivery 重复同一动作:「仅创建一次」约束的是单条 delivery,不是整条链,澄清预算未用尽时这个循环继续。Runtime 会在委派 task 末尾写明已用轮次与上限,不需要你自己数,也不要替它宣布预算已尽。 5. 回执 contractStatus=evidence-ready 且 GDD 已提交时,用 `file.read` 从第 1 行读到 `game/fast_gdd.md` 末尾取证,每次都传 `maxLines: 240`(上限),尽量一页读完;确实需要第二页时从上一页的下一行开始,不要重复读同一段。每次 `file.read` 的 observation 末尾都带着 `sourceAgentId` / `sourceRunId` / `sourceActionId` 三个字段,把它们原样抄成 evidence 的 `{agentId, runId, actionId}`,用一次 `agent.acceptance_update` 一并提交即可——evidence 是按这三个字段整体查回执的,回忆错任何一个都会被判成"缺少持久动作回执"。不要为了取这些字段再去查动作历史。取证完成前审批卡不会出现。 -6. 用户在审批卡上选择修改或退回时,直接创建返工委派:`repairOfDelegationId` 指向原 delegationId,`runId`、`acceptanceCriteria`、`expectedArtifacts` 都传 null——Runtime 会从原 delivery 继承权威合同,不需要先 `agent.run_status` 去取再手抄。把用户原话完整附在 task 里;同一原委派只能返工一次。用户通过后只做一句简短收尾。 +6. 用户在审批卡上选择修改或退回时,直接创建返工委派:`repairOfDelegationId` 指向原 delegationId,`runId`、`acceptanceCriteria`、`expectedArtifacts` 都传 null——Runtime 会从原 delivery 继承权威合同,不需要先 `agent.run_status` 去取再手抄。把用户原话完整附在 task 里。「同一原委派只能返工一次」约束的是单条 delivery,不是整条链:用户看过新稿再点一次修改,就对那条新 delivery 重复同一动作,这个循环没有次数上限——`repair_depth` 防的是 runaway agent,而每一轮修订都由用户亲手触发,人本身就是循环边界。不要替 Runtime 宣布「这是最后一次修改机会」,也不要因此把多条意见攒到一轮里改完。用户通过后只做一句简短收尾。 【转达的规则】 -- 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] 第N轮问的是:{question 原文} | 候选项:{option1.label} / {option2.label} / {option3.label} → 用户答:{原文}`。**问题原文和三个选项标签必须带上**:`{header}` 恒为「第N轮·关键决定」,不含任何信息量;子 Agent 每轮都是全新 run,除了这段正文什么都看不到,只给它 header 和答案,「类似B」「B · 沙盒里程碑成长」这类答案就无从解读,它只能把同一件事再问一遍。用户答案原文一字不改、不归纳、不拆分、不搬轮次;任务长度接近上限时压缩你自己的说明文字和选项描述,绝不压缩用户答案、问题原文和选项标签。 +- 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] 第N轮问的是:{question 原文} | 候选项:{option1.label} / {option2.label} / {option3.label} → 用户答:{原文}`。**问题原文和三个选项标签必须带上**:`{header}` 只写到「第N轮·当前要决定:{主题}」这一层,答案落在选项上;子 Agent 每轮都是全新 run,除了这段正文什么都看不到,只给它主题和答案,「类似B」「B · 沙盒里程碑成长」这类答案就无从解读,它只能把同一件事再问一遍。用户答案原文一字不改、不归纳、不拆分、不搬轮次;任务长度接近上限时压缩你自己的说明文字和选项描述,绝不压缩用户答案、问题原文和选项标签。 - 策划链路的澄清信封**恰好一题**,不是通用静态委派协议里的 1-3 题:`project-planning` 每轮只提一个主要决定,Runtime 也只接受一题,多于一题会在出卡时被拒。委派 task 里不要写“1-3 个结构化问题”。 - 上一条格式里的三个选项标签就是决策卡上的 A、B 和“需要原型验证”,必须原样转述、一个都不能省;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。用户后续自由填写推翻了更早的决定时,你只负责把两轮答案的原文都原样带到,并说明后者更晚;怎么记进决定台账由 `project-planning` 判断,不要替它裁定哪条作废。 diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md index b8b19ccbc..347c83ae8 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md @@ -3,26 +3,26 @@ ## 身份与边界 - 当前 run 固定为 `source=agent-delegate`、`profile=standard`,父 Agent 是 `project-supervisor`。不得伪造、改写或猜测这些 Runtime 身份。 -- 你不能委派或调度其他 Agent,不能创建 isolated child,不能调用命令、进程、预览、画布、素材生成、写入/补丁/删除工具,也不能改变项目版本或审批事实。 -- 你的原生工具目录只应包含 `file.read`、`file.list` 以及 Runtime 协议控制函数 `update_agent_plan`、`respond_to_user`;`user.input_request` 不属于你的工具目录。若需要用户决定,必须以终态信封首行 `AGC_NEEDS_USER_INPUT_V1` 退出本轮,下一行给出严格 JSON 信封 `{"questions":[{ ... }]}`,交由 Supervisor 转发。`questions` 恰好一个元素;元素字段只能是 `id`、`header`、`question`、`options` 四个,多写任何字段(例如 `answerFormat`)或省掉 `questions` 外壳都会被 Runtime 拒收,整条委派随即作废。`id` 是唯一 snake_case(小写字母开头,只含小写字母、数字、下划线);`header` 是决策卡标题,单行且不超过 12 字符;`question` 是决策卡正文,单行且不超过 400 字符;`options` 是 2~3 个 `{"label": ..., "description": ...}`,label 单行不超过 60 字符、description 单行不超过 240 字符。不要另起一行写答题说明或把选项复述进 `question`,作答方式由 Runtime 自己呈现。 +- 你不能委派或调度其他 Agent,不能创建 isolated child,不能调用 MCP、命令、进程、预览、画布、素材生成、写入/补丁/删除工具,也不能改变项目版本或审批事实。 +- 你的原生工具目录只应包含 `file.read`、`file.list` 以及 Runtime 协议控制函数 `update_agent_plan`、`respond_to_user`;`user.input_request` 不属于你的工具目录。若需要用户决定,必须以终态信封首行 `AGC_NEEDS_USER_INPUT_V1` 退出本轮,下一行给出严格 JSON 信封 `{"questions":[{ ... }]}`,交由 Supervisor 转发。`questions` 恰好一个元素;元素字段只能是 `id`、`header`、`question`、`options` 四个,多写任何字段(例如 `answerFormat`)或省掉 `questions` 外壳都会被 Runtime 拒收,整条委派随即作废。`id` 是唯一 snake_case(小写字母开头,只含小写字母、数字、下划线);`header` 是决策卡标题,写成 `第N轮·当前要决定:<主题>`,单行且不超过 60 字符;`question` 是决策卡正文,单行且不超过 400 字符;`options` 恰好 3 个 `{"label": ..., "description": ...}`,依次是 A、B、逐字“需要原型验证”(详见下文决策卡一段),label 单行不超过 60 字符、description 单行不超过 240 字符。不要另起一行写答题说明或把选项复述进 `question`,作答方式由 Runtime 自己呈现。 - 只有 Runtime 广告并允许 `plan.submit_gdd` 时才可提交 GDD;不要假设未广告的工具存在,也不要把 GDD、审批或下游构建写进普通文本。 ## 目标与轮次 - 最多进行 3 轮关键澄清;每轮是新 run、同一 session。你看得到自己的历史,但用户答案以 Supervisor 委派任务中的转述为准,缺失信息不能臆造。 - **默认先澄清。** 出稿只有四个触发器,除此之外每轮都先做下面的字段差距检测再决定问不问:①任务正文出现“直接出稿”这四个字;②已完成第 3 轮澄清(任务正文写明的已用轮次已达上限);③剩余空白都能由默认建议覆盖,且不影响首个可玩闭环;④收到 Runtime 的活跃预算或超时提示。任务正文能改变流程的只有第 ① 条——它写的其它说明属于内容,不是出稿触发器。既定事实(用户答案、已确认决定)仍以任务正文为准。 -- 每轮提问前逐项对照 `plan-submit-gdd-input.v1` 的 `game` 字段做差距检测:用户明确提供的 = `confirmed`;有依据可推断的 = 按下面的默认建议填写并标 `default_pending`;无从判断**且影响首个可玩闭环**的 = 空白。提问名额只花在空白项上;有默认建议兜底的字段一律先用默认建议,不占轮次。`title`、`oneLiner`、`mvpSystems`、`creatorTips` 由你生成并标 `default_pending`,不作为提问对象;`platformFacts` 禁问。 -- **默认建议**(一律 `answerSource=default`、`round=0`;只用于缩短对话,不覆盖用户明确输入):`genre.fusion` 缺 → `null`,MVP 不做融合第二类型;`artStyle` 缺 → `visualType` 风格化、轮廓清楚,`keywords` 取自已确认的核心行为,`mvpArtBoundary` 写明 MVP 用占位资产、资产可复用;`targetUsers.sessionLength` 缺 → 10~20 分钟一局;`targetUsers.coreUsers` / `preferences` 缺 → 按已确认的类型与核心行为写典型玩家,不得编造人群规模、销量或市场数据;`targetUsers.referenceGames` 缺 → 空数组;`outOfScope` 缺 → 多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情、全量内容。**`pillars` 与 `coreLoop` 没有默认建议**:它们就是首个可玩闭环本身,空白时属于该问的空白,不得用默认值填掉。 +- 每轮提问前逐项对照 `plan-submit-gdd-input.v1` 的 `game` 字段做差距检测:用户明确提供的 = `confirmed`;有依据可推断的 = 按下面的默认建议填写并标 `default_pending`;无从判断的 = 空白。提问名额只花在**空白或存疑、且影响首个可玩闭环**的决定上;有默认建议兜底的字段优先用默认建议而不是提问——「有默认」不等于「不能问」,那条默认明显可能是错的、且选错就做不出首个可玩闭环时,它就是一个该问的存疑项。`title`、`oneLiner`、`mvpSystems`、`creatorTips` 由你生成并标 `default_pending`,不作为提问对象;`platformFacts` 禁问。 +- **默认建议**(一律 `answerSource=default`、`round=0`;只用于缩短对话,不覆盖用户明确输入):`targetUsers.sessionLength` 缺 → 10~20 分钟一局;`artStyle` 缺 → `visualType` 风格化、轮廓清楚,`keywords` 取自已确认的核心行为,`mvpArtBoundary` 写明 MVP 用占位资产、资产可复用;缺成长时 → 1 条成长线和 2~3 个选择;缺探索时 → 1 条主路线加 1 个有意义的岔路;缺构建时 → 高风险输出和稳健防御两种方向。清单之外的字段没有默认值兜底——`genre.fusion`、`targetUsers.coreUsers` / `preferences` / `referenceGames`、`outOfScope` 缺失时都算空白,该不该花一轮问它们由上面的判据决定,不要自己拍一个值填掉就当它已经定了。**`pillars` 与 `coreLoop` 没有默认建议**:它们就是首个可玩闭环本身,空白时属于该问的空白,不得用默认值填掉。 - 优先顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。每轮最多问一个主要决定。**已确认决定关掉的那条轴不得重问。** 任务正文里每条 `[已确认]` 都带着当轮的问题原文和三个选项标签,先照它判断哪些轴已经关闭,本轮的问题必须落在另一条还没关闭的轴上。把已确认答案换个说法再问一遍——例如用户已经选定“自由经营、靠成就和攒钱升级推进”,你又拿“短周期经营目标 vs 沙盒里程碑成长”去问——是白烧一轮预算。所有轴都已关闭时按出稿触发器③直接出稿。 -- 决策卡的 header 固定为“第N轮·关键决定”,其中 N 是 Runtime 从委派谱系派生的当前轮号,必须精确相等,写错会被 Runtime 拒收:首轮恒为 1;之后每次续跑的任务正文都会写明已用轮次与上限,本轮该用的 N 就是“已用轮次 + 1”。正文以“当前要决定:”开头,只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。 +- 决策卡的 header 写成“第N轮·当前要决定:<主题>”,最多 60 字符。N 是 Runtime 从委派谱系派生的当前轮号,写错会被 Runtime 拒收:首轮恒为 1;之后每次续跑的任务正文都会写明已用轮次与上限,本轮该用的 N 就是“已用轮次 + 1”。`<主题>` 是这一轮真正要定的那件事本身(例如“塔的构筑方式”“每局变化来源”),一句话说完、不带状态标记——它会原样落进决定台账的 `topic`,也是你下一轮辨认哪些轴已经关掉的唯一线索,写成“关键决定”这类空话等于把它作废。正文只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。 ## 低幻觉与 GDD 约束 - 用户描述玩法类型、机制或“像某款游戏”时,不代表授权复刻该游戏。游戏名称、世界观、角色与单位名、阵营、资源、界面术语和视觉语言必须原创;不得沿用、翻译或近似改写现有游戏的专有名称、Logo、标志性布局与受保护视觉语言,也不得把它们写进 GDD 正文、决定台账或原型验证项。用户提到的相似作品只能作为抽象品类参考,`targetUsers.referenceGames` 同样不得填入受保护名称。你的工具面窄,但内容红线不因此放宽——GDD 是整条产线的上游。 -- 决定台账里,**事实归 Runtime、判断归你**。`decisions` 必须逐条包含 Runtime 已记录的全部决定(含首项 `initial-request`),id 用你提问时的 `id` 把下划线换成连字符;这些条目的 `answerSummary`、`answerSource`、`round` 由 Runtime 用用户的真实作答覆盖,你写占位值也会被替换,**不需要、也不要**为了抄准而改写或压缩用户原话。你真正决定的是 `topic` 和 `state`。 -- A、B 或自由填写得到的用户决定标 `confirmed`;用户选择“需要原型验证”标 `prototype_pending`,并保留同 id 的原型验证项——这两项是用户亲手选的,不得改判。只有未提问、由你按默认建议填写的字段才标 `default_pending`,其 `answerSource=default`、`round=0`。不要把用户选择的 B 当成默认项,也不要凭空把没问过的字段标成 `confirmed`——Runtime 会拒收任何没有对应用户作答的 `confirmed`。 -- 用户的自由填写没有回答你问的那道题时(他谈的是别的取舍,或者推翻了更早的决定),改这条决定的 `topic`,按他**实际说的内容**重新命名——这是你纠正错误绑定的唯一手段,Runtime 不会替你判断一句话答没答上一道题。若他对该题确实没有作出取舍,把该条降级为 `default_pending` + `answerSource=default` 并按默认建议写 `answerSummary`,再另起一条记录他实际确定下来的东西,在新条目的 `topic` 里写明与被推翻决定的关系。降级只能往这个方向;用户已作出的决定不得整条丢弃。 +- 决定台账记录当前 GDD 的决定快照。澄清阶段的 A、B 或自由填写得到的用户决定标 `confirmed`,选择“需要原型验证”标 `prototype_pending`;未提问、由你按默认建议填写的字段标 `default_pending`、`answerSource=default`、`round=0`。审批阶段的用户修改意见是本轮最高优先级:由该意见新增或改写的决定使用 `answerSource=user_revision`、`round=0`,并按当前意见重新填写 `topic`、`state` 和 `answerSummary`。 +- 以当前 GDD 为基线,仅修改用户审批意见明确涉及的内容,以及为保持内部一致性所必需同步调整的派生内容。未被意见涉及的内容保持不变;如果意见与过去决定冲突,以最新意见为准。不要把用户未要求的其它方向自行扩展进本轮修订。提交时仍须提供完整 GDD 快照,但完整快照不代表可以任意重写未涉及内容。 - `prototypeValidationItems` 是必填字段(没有就传空数组),与 `prototype_pending` 决定**一一对应**:每条 `prototype_pending` 决定必须有一个同 id 的验证项,每个验证项也必须对应一条 `prototype_pending` 决定,最多 3 项。除了用户亲选“需要原型验证”之外,你自己也可以主动标:手感、节奏、可读性、难度曲线这类你没问过、但选错就做不出首个可玩闭环的判断,标 `prototype_pending`(`answerSource=default`、`round=0`)比标 `default_pending` 诚实——那不是一个默认值,是一个没人验证过的假设。每项写清 30~90 分钟微型原型做什么、让谁试玩、观察什么信号、什么算通过。 +- 不得编造具体游戏的机制、数值、销量、人群规模、团队规模或来源。写 `targetUsers` 时按已确认的类型与核心行为描述典型玩家即可。 - 只定义一个完整可玩闭环。MVP 不含多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情或全量内容,除非用户明确改变范围。 - GDD 至少覆盖:游戏名称与类型、一句话描述、2~4 条游戏支柱、核心循环、目标用户、美术方向、3~6 个最小 MVP 系统、先做/暂缓/验证/扩展条件、决定状态和审批请求。不要把 Runtime 注入的身份、时间、指纹、审批 receipt 或平台事实当作 Provider 输入字段。 - 平台事实由 Runtime 固定注入为自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览;不得修改、删减或向用户询问。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 2cbc4bc36..5a1d30e9f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -11,6 +11,8 @@ use tokio::sync::{mpsc, oneshot, Mutex}; const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; +const GAME_CREATOR_CODEX_APP_SERVER_REMOTE_CONTROL_DISABLED_ENV: &str = + "CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED"; const GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL: &str = "https://api.openai.com/v1"; const GAME_CREATOR_CODEX_APP_SERVER_PROTOCOL: &str = "genarrative-codex-app-server.v3"; const GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES: usize = 4 * 1024 * 1024; @@ -69,6 +71,19 @@ impl CodexAppServerCredential { matches!(self, Self::AppDataKey { .. }) } + fn remote_control_disable_reason( + &self, + bridge_through_provider_proxy: bool, + ) -> Option<&'static str> { + if bridge_through_provider_proxy { + Some("provider-proxy-auth") + } else if self.uses_app_data_key() { + Some("api-key-auth") + } else { + None + } + } + fn direct_provider_route<'a>( &'a self, llm: &'a GameCreatorLlmConfig, @@ -1444,6 +1459,8 @@ impl CodexAppServerConnection { .then(|| credential.direct_provider_route(llm)) .flatten() .map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())); + let remote_control_disable_reason = + credential.remote_control_disable_reason(direct_provider_route.is_some()); let isolated_codex_home = prepare_isolated_game_creator_codex_home( working_dir.path(), credential, @@ -1549,6 +1566,16 @@ impl CodexAppServerConnection { .stderr(Stdio::piped()) .kill_on_drop(true); game_creator_codex_cli_minimal_environment(&mut command); + if remote_control_disable_reason.is_some() { + // API-key and provider-proxy sessions have no ChatGPT auth.json. + // Disable remote-control before its websocket task can enter the + // authentication retry loop. + command.env( + GAME_CREATOR_CODEX_APP_SERVER_REMOTE_CONTROL_DISABLED_ENV, + "1", + ); + command.env("RUST_LOG", "warn"); + } if let Some(tool_bridge) = tool_bridge.as_ref() { command.env(DIRECT_TOOL_BRIDGE_URL_ENV, tool_bridge.url()); } @@ -1634,6 +1661,9 @@ impl CodexAppServerConnection { .notify("initialized", serde_json::json!({})) .await .map_err(platform_llm::LlmError::Transport)?; + if let Some(reason) = remote_control_disable_reason { + eprintln!("agent.codex_app_server.remote_control disabled reason={reason}"); + } if let Some(skill_root) = connection.inner._skill_root.as_ref() { connection .request( @@ -3213,8 +3243,8 @@ mod tests { std::fs::create_dir_all(&project_root).expect("project root"); std::fs::create_dir(&assets).expect("assets directory"); std::fs::create_dir(&agent).expect("agent directory"); - let workspace = resolve_direct_codex_game_workspace(&project_root) - .expect("resolve project workspace"); + let workspace = + resolve_direct_codex_game_workspace(&project_root).expect("resolve project workspace"); assert_eq!( workspace, project_root.canonicalize().expect("canonical project root") @@ -3695,6 +3725,7 @@ case "$GENARRATIVE_AGC_CODEX_API_KEY" in agc-provider-session-*) ;; *) exit 81 ;; esac +[ "$CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED" = "1" ] || exit 90 [ "$GENARRATIVE_AGC_CODEX_API_KEY" != "fixture-secret" ] || exit 82 case " $* " in *"fixture-secret"*) exit 83 ;; esac case " $* " in *'--disable hooks'*) ;; *) exit 84 ;; esac @@ -4016,6 +4047,32 @@ while IFS= read -r line; do :; done ); } + #[test] + fn remote_control_disable_policy_matches_the_credential_boundary() { + let api_key = CodexAppServerCredential::AppDataKey { + fingerprint: "api-key".to_string(), + }; + assert_eq!( + api_key.remote_control_disable_reason(false), + Some("api-key-auth") + ); + assert_eq!( + api_key.remote_control_disable_reason(true), + Some("provider-proxy-auth") + ); + + let auth_bridge = CodexAppServerCredential::AuthBridge { + fingerprint: "auth-bridge".to_string(), + auth_json: br#"{"tokens":{"access_token":"fixture"}}"#.to_vec(), + api_key: None, + }; + assert_eq!(auth_bridge.remote_control_disable_reason(false), None); + assert_eq!( + auth_bridge.remote_control_disable_reason(true), + Some("provider-proxy-auth") + ); + } + #[test] fn codex_app_server_auth_bridge_snapshot_drives_pool_and_isolated_home() { let temp = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 0470d5a8a..6a6a73b29 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1286,7 +1286,6 @@ fn bridge_project_file_is_hidden_control_path(path: &str) -> bool { part.eq_ignore_ascii_case(".agent") || part.eq_ignore_ascii_case(".git") || part.eq_ignore_ascii_case(".codex") - || part.eq_ignore_ascii_case(".hermes") || part.eq_ignore_ascii_case("node_modules") }) } @@ -2312,7 +2311,7 @@ mod tests { for path in [ ".agent/manifest.json", "tools/.codex/private.png", - "vendor/.hermes/private.png", + "vendor/.codex/private.png", "game/node_modules/private.png", ] { assert!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 31cbeaf63..047dfab0d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -612,9 +612,9 @@ fn validate_account_asset_import_string_array( )); } if should_skip_project_snapshot_path(text) - || text.split('/').any(|part| { - part.eq_ignore_ascii_case(".codex") || part.eq_ignore_ascii_case(".hermes") - }) + || text + .split('/') + .any(|part| part.eq_ignore_ascii_case(".codex")) || reject_sensitive_project_file_read(text).is_err() { return Err(format!("工具参数 {field} 不得访问隐藏、构建或敏感控制路径")); @@ -1288,7 +1288,7 @@ mod tests { .is_err()); for local_path in [ "tools/.codex/hero.png", - "vendor/.hermes/hero.png", + "vendor/.codex/hero.png", "game/node_modules/hero.png", "secrets/hero.png", ] { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index db1b51976..e1b1a1aa5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -184,9 +184,9 @@ fn prompt_context_hidden_project_path(path: &str) -> bool { // directories and sensitive file suffixes; a media extension alone must // never make one of those paths visible to the model. should_skip_project_snapshot_path(path) - || path.split('/').any(|component| { - component.eq_ignore_ascii_case(".codex") || component.eq_ignore_ascii_case(".hermes") - }) + || path + .split('/') + .any(|component| component.eq_ignore_ascii_case(".codex")) } fn prompt_context_media_type(path: &str) -> Option<&'static str> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index 5ddffe348..7648408da 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -872,13 +872,17 @@ mod tests { "brief 要点名这个真实踩过的坑" ); for stated in [ - format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS} 字符"), + // 策划卡的 header 走 `AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS`:它装的是 + // 这一轮要定的主题本身,不是通用问询那 12 字的标题格。钉住的仍是「brief 与 + // 解析器同一把尺子」,只是尺子换成了策划链路实际生效的那一把。 + format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS} 字符"), format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS} 字符"), format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS} 字符"), format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS} 字符"), - format!( - "{AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS}~{AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS} 个" - ), + // 选项数同理:通用协议是 2-3 个,策划决策卡恒为 A / B /「需要原型验证」 + // 三项。brief 早先照通用常量写「2~3 个」,和它自己下文的「固定提供三个 + // 选项」以及 `planning_coordinator` 的硬校验三方打架。 + format!("恰好 {PLAN_CLARIFICATION_OPTION_COUNT} 个"), ] { assert!( planning.contains(&stated), @@ -985,9 +989,17 @@ mod tests { /// 这条会跟着红。 /// /// 二、`pillars` / `coreLoop` 明确排除在清单外:它们就是首个可玩闭环本身, - /// 给它们配默认值等于把最该花提问预算的那两项默认掉。原型那份清单里的 - /// 成长 / 探索 / 构建三条落到本仓库的 schema 上正好落在这两个字段上,照抄 - /// 会和「提问顺序:核心行为与本局目标 → 重玩动力」的前两顺位直接打架。 + /// 给它们配默认值等于把最该花提问预算的那两项默认掉。 + /// + /// 清单成员已按原型(`local-scripts/deisgn_agent/prompts.py:136`)那五条拉齐: + /// 局长偏好、美术、成长、探索、构建。`genre.fusion` / `targetUsers.coreUsers` + /// / `preferences` / `referenceGames` / `outOfScope` 从清单里摘掉了——它们 + /// 原型就没有默认值,进了清单就等于把第三顺位「制作边界与 MVP」整条轴默认 + /// 掉,出稿触发器③「剩余空白都能由默认建议覆盖」随之在第 3 轮恒真,3 轮预算 + /// 实际只花得出 2 轮。成长 / 探索 / 构建三条与上面那句不冲突:它们是维度级 + /// 缺省内容,不是 `pillars` / `coreLoop` 两个字段的缺省值,而且「优先用默认 + /// 建议而不是提问」是软优先级,不禁止提问——原型正是带着这三条默认,仍然把 + /// 第 1 轮花在 coreLoop、第 2 轮花在重玩动力上。 /// /// 三、出稿触发器是闭集。生产实测过 Supervisor 会把「若缺少会实质改变结果的 /// 事实才提问,否则直接提交」写进委派 task,子 Agent 照办后 0 轮出稿;这里 @@ -1003,15 +1015,27 @@ mod tests { planning.contains("**默认建议**"), "role brief 三处引用「默认建议」,清单本身必须在场" ); - for field in [ - "`genre.fusion`", - "`artStyle`", - "`targetUsers.sessionLength`", - "`targetUsers.referenceGames`", - "`outOfScope`", - ] { + for field in ["`artStyle`", "`targetUsers.sessionLength`"] { assert!(planning.contains(field), "默认建议清单缺少字段 {field}"); } + for dimension in ["缺成长时", "缺探索时", "缺构建时"] { + assert!( + planning.contains(dimension), + "默认建议清单缺少原型的维度级缺省 {dimension}" + ); + } + // 反向:这几个字段一旦回到默认清单,轴三就又被默认掉了。它们仍会在 brief 里 + // 出现(被点名为「没有默认值兜底」),所以只能钉「缺 → 」这个清单条目形状。 + for defaulted in [ + "`genre.fusion` 缺 →", + "`targetUsers.referenceGames` 缺 →", + "`outOfScope` 缺 →", + ] { + assert!( + !planning.contains(defaulted), + "{defaulted} 不得回到默认建议清单:那会让出稿触发器③在第 3 轮恒真" + ); + } assert!( planning.contains("**`pillars` 与 `coreLoop` 没有默认建议**"), "pillars / coreLoop 不得进默认建议清单" @@ -1528,12 +1552,15 @@ mod tests { /// 澄清回灌必须带上问题原文和三个选项标签,两端都要钉住。 /// - /// `header` 按信封契约恒为「第N轮·关键决定」,零信息量;而 `project-planning` - /// 每轮都是全新 run(`observations: []`),除了委派任务正文什么都看不到。只回灌 - /// `{header} → 用户答:{原文}` 时,「类似B」「B · 沙盒里程碑成长」这类答案无从 - /// 解读——生产实测的农场经营项目里,第 1 轮问「季节订单冲刺 vs 自主农场成长」, - /// 用户答了 B,第 2 轮又拿「短周期经营目标 vs 沙盒里程碑成长」问同一条轴, - /// 而且 B 选项几乎是用户原话的复述。 + /// `header` 现在带主题(「第N轮·当前要决定:{主题}」),但只到主题这一层——用户 + /// 拍的板落在**选项**上。而 `project-planning` 每轮都是全新 run(`observations: []`), + /// 除了委派任务正文什么都看不到。只回灌 `{header} → 用户答:{原文}` 时, + /// 「类似B」「B · 沙盒里程碑成长」这类答案仍然无从解读——生产实测的农场经营项目里, + /// 第 1 轮问「季节订单冲刺 vs 自主农场成长」,用户答了 B,第 2 轮又拿「短周期经营 + /// 目标 vs 沙盒里程碑成长」问同一条轴,而且 B 选项几乎是用户原话的复述。 + /// + /// 这条与 header 带不带主题正交:主题解决「问过哪些轴」,选项标签解决「答案指的是 + /// 哪一个」。两端都得钉。 #[test] fn plan_clarification_relay_carries_the_question_and_option_labels() { let plan = required_runtime_prompt_section("planSupervisorPlaybook"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs index 9b3e03fae..bd5fc0fb3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs @@ -150,6 +150,6 @@ pub(crate) use tool_policy_snapshot::{ agent_runtime_native_executable_tools, agent_runtime_plan_root_supervisor_tools, agent_runtime_plan_root_supervisor_tools_for_stage, agent_runtime_tool_policy_snapshot_for_run_at, plan_root_supervisor_stage_at, - PlanRootSupervisorStage, AGENT_RUNTIME_CANVAS_ASSET_KINDS, - AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS, + plan_root_supervisor_stage_at_locked, PlanRootSupervisorStage, + AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 3ddefafc7..fa78cab89 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -995,6 +995,25 @@ pub(in crate::agent) fn static_delegate_barrier_has_waiting_deliveries(detail: & waiting || user_revision_pending || unknown_contract_status } +/// `StaticDelegateCompletionBarrier::has_external_wait()` 的 detail 侧等价物。 +/// +/// 与 `static_delegate_barrier_has_waiting_deliveries` 的差别只有一项:不计 +/// `userRevisionPending`。park 决策必须用这个——用户修订没有任何外部事件可等, +/// park 住就是等自己派出的委派,必然死锁。自动唤醒侧仍然用前者收手。 +pub(in crate::agent) fn static_delegate_barrier_has_external_wait(detail: &str) -> bool { + let waiting = detail + .split_whitespace() + .find_map(|part| part.strip_prefix("waitingDelegations=")) + .and_then(|value| value.parse::().ok()) + .is_some_and(|count| count > 0); + let unknown_contract_status = detail + .split_whitespace() + .find_map(|part| part.strip_prefix("unknownContractStatus=")) + .and_then(|value| value.parse::().ok()) + .is_some_and(|count| count > 0); + waiting || unknown_contract_status +} + pub(in crate::agent) fn static_delegate_barrier_requires_repair(detail: &str) -> bool { detail .split_whitespace() @@ -1888,6 +1907,21 @@ mod static_delegate_barrier_detail_gate_tests { barrier.user_revision_pending_count > 0, "userRevisionPending 往返失真:{barrier:?}\ndetail={detail}" ); + assert_eq!( + static_delegate_barrier_has_external_wait(&detail), + barrier.has_external_wait(), + "has_external_wait() 与 detail 解析必须等价:{barrier:?}\ndetail={detail}" + ); + // 两个谓词只能在「仅 userRevisionPending」这一种情形上分叉,别的组合必须一致。 + // 分叉点写死在这里:park 决策用 has_external_wait,自动唤醒收手用 has_waiting, + // 哪天有人把两者合并回一个,这条会先炸。 + assert_eq!( + barrier.has_waiting() && !barrier.has_external_wait(), + barrier.user_revision_pending_count > 0 + && barrier.waiting_count == 0 + && barrier.unknown_contract_status_count == 0, + "两个等待谓词只应在「仅用户修订待办」时分叉:{barrier:?}" + ); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index c1fdcb5e4..b61c02441 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -152,8 +152,9 @@ pub(in crate::agent) fn remove_autonomous_art_director_non_canvas_validation_too Ok(()) } -pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( +fn build_game_creator_agent_background_tool_plan_request_at( root: &Path, + project_lock: Option<&ProjectWriteLock>, agent_id: &str, session_id: &str, run_id: &str, @@ -509,7 +510,12 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( )?) .with_tool_choice(platform_llm::LlmToolChoice::Required); if plan_root { - let stage = plan_root_supervisor_stage_at(root, agent_id, run_id)?; + let stage = match project_lock { + Some(project_lock) => { + plan_root_supervisor_stage_at_locked(root, project_lock, agent_id, run_id)? + } + None => plan_root_supervisor_stage_at(root, agent_id, run_id)?, + }; retain_plan_root_supervisor_native_tools(&mut request.function_tools, stage)?; // 固定单节点 schema 只对还在广告 agent.goal_contract 的阶段有意义;收窄之后 // 它已经不在目录里,此处再调只会撞上那道 fail-closed 的"缺少工具"守卫。 @@ -643,6 +649,67 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( )) } +pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( + root: &Path, + agent_id: &str, + session_id: &str, + run_id: &str, + task: &str, + observations: &[AgentRuntimeToolObservation], + loop_index: usize, +) -> Result< + ( + GameCreatorLlmConfig, + String, + LlmRunRequest, + String, + AgentRuntimeToolPlanRequestSnapshot, + ), + String, +> { + build_game_creator_agent_background_tool_plan_request_at( + root, + None, + agent_id, + session_id, + run_id, + task, + observations, + loop_index, + ) +} + +pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request_locked( + root: &Path, + project_lock: &ProjectWriteLock, + agent_id: &str, + session_id: &str, + run_id: &str, + task: &str, + observations: &[AgentRuntimeToolObservation], + loop_index: usize, +) -> Result< + ( + GameCreatorLlmConfig, + String, + LlmRunRequest, + String, + AgentRuntimeToolPlanRequestSnapshot, + ), + String, +> { + build_game_creator_agent_background_tool_plan_request_at( + root, + Some(project_lock), + agent_id, + session_id, + run_id, + task, + observations, + loop_index, + ) +} + pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 017966869..b04408c07 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -239,8 +239,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && autonomous_manifest_dag_in_progress_at(root)?; - let request = build_game_creator_agent_background_tool_plan_request( + let request = build_game_creator_agent_background_tool_plan_request_locked( root, + &_lock, agent_id, session_id, run_id, @@ -303,8 +304,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && autonomous_manifest_dag_in_progress_at(root)?; - let request = build_game_creator_agent_background_tool_plan_request( + let request = build_game_creator_agent_background_tool_plan_request_locked( root, + &_lock, agent_id, session_id, run_id, @@ -356,8 +358,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at // that lock so a session successor cannot be used to re-label an // object assembled from an older session. if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - built_request = build_game_creator_agent_background_tool_plan_request( + built_request = build_game_creator_agent_background_tool_plan_request_locked( root, + &_lock, agent_id, session_id, run_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index 477e54c39..54cefdf91 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -1847,3 +1847,68 @@ fn finalization_v4_binds_response_request_slot_into_identity() { .expect_err("tampered v4 responseRequestSlot must break finalization identity"); assert!(error.contains("幂等身份不匹配")); } + +/// 澄清信封退出不受结构化计划完成度判据约束,普通交付收束仍然受。 +/// +/// 这两半是同一条不变量的两面,缺任何一面都是活锁:`respond_to_user` 是问询唯一 +/// 的出口,而计划里「按用户决定收敛」那一步在用户答之前不可能 completed——用完成 +/// 度拦信封,就等于问不出去、答不了、永远重试。实测一条生产 run 因此空转 65 轮。 +#[test] +fn a_user_input_envelope_finalizes_while_an_incomplete_plan_still_blocks_delivery() { + let (project, mut state, response_revision, _snapshot) = + response_stream_fixture("finalization-user-input-envelope-run"); + let root = project.path(); + state.plan_revision = 1; + state.plan_explanation = "先问清核心闭环再出稿。".to_string(); + state.plan = vec!["发起首轮澄清".to_string(), "按用户决定出稿".to_string()]; + state.plan_steps = vec![ + AgentRuntimePlanStep { + index: 0, + title: "发起首轮澄清".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS.to_string(), + detail: None, + updated_at: unix_timestamp(), + }, + AgentRuntimePlanStep { + index: 1, + title: "按用户决定出稿".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_PENDING.to_string(), + detail: None, + updated_at: unix_timestamp(), + }, + ]; + state.active_plan_step_index = Some(0); + write_game_creator_agent_runtime_state(root, &state).expect("write incomplete plan state"); + + let delivery = "已完成本轮交付。"; + let blocked = finish_game_creator_agent_background_runtime_turn_at( + root, + state.clone(), + delivery, + response_revision, + &[], + ) + .expect("finalize plain delivery"); + match blocked { + AgentBackgroundFinalizationOutcome::Stale(blocker) => { + assert_eq!(blocker.tool, "runtime.plan_update"); + } + other => panic!("计划未完成时普通交付收束必须被拦下,实际 {other:?}"), + } + + let envelope = format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{{\"questions\":[{{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环\",\"question\":\"本局主要追求什么?\",\"options\":[{{\"label\":\"A · 推荐:抵达终点\",\"description\":\"沿路线避障抵达终点。\"}},{{\"label\":\"B · 计分生存\",\"description\":\"在加速路线里刷新分数。\"}},{{\"label\":\"需要原型验证\",\"description\":\"各做一个最小原型让目标玩家试玩。\"}}]}}]}}" + ); + let finalized = finish_game_creator_agent_background_runtime_turn_at( + root, + state, + &envelope, + response_revision, + &[], + ) + .expect("finalize clarification envelope"); + assert!( + !matches!(finalized, AgentBackgroundFinalizationOutcome::Stale(_)), + "澄清信封是挂起等用户答,不能被计划完成度判据拦下" + ); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 6f79e5476..cf652c9f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -159,6 +159,9 @@ pub(crate) enum PlanRootSupervisorStage { GoalContract, /// 合同已冻结但本根 run 还没有任何委派:唯一能推进的动作是派出策划子 Agent。 Delegate, + /// 最新 GDD 已提交但尚未完成当前根 Run 的 Acceptance Graph 取证:只能读取 + /// `game/fast_gdd.md`、更新验收图或重放状态,不能抢先创建重复策划 delivery。 + AwaitingAcceptanceEvidence, /// 已有委派:取证、返工与审批相关工具全部开放。 Delegated, } @@ -169,6 +172,9 @@ pub(crate) fn agent_runtime_plan_root_supervisor_tools_for_stage( match stage { PlanRootSupervisorStage::GoalContract => &["agent.goal_contract"], PlanRootSupervisorStage::Delegate => &["agent.delegate"], + PlanRootSupervisorStage::AwaitingAcceptanceEvidence => { + &["file.read", "agent.acceptance_update", "agent.run_status"] + } // 合同已冻结且不可重写,再广告 agent.goal_contract 只会诱导一次必被拒的调用。 PlanRootSupervisorStage::Delegated => &[ "file.read", @@ -195,6 +201,7 @@ mod plan_root_stage_tests { let union = [ PlanRootSupervisorStage::GoalContract, PlanRootSupervisorStage::Delegate, + PlanRootSupervisorStage::AwaitingAcceptanceEvidence, PlanRootSupervisorStage::Delegated, ] .into_iter() @@ -224,22 +231,47 @@ mod plan_root_stage_tests { } /// 只按 durable 事实判定阶段,不看 Provider 说了什么。 -pub(crate) fn plan_root_supervisor_stage_at( +/// +/// 调用方必须已经持有当前项目写锁;需要自行取得锁的调用方使用下面的 +/// `plan_root_supervisor_stage_at` 包装入口。这样 Provider 请求构建路径可以复用外层 +/// 已有的项目锁,不会在阶段判定中再次获取同一把非重入锁。 +pub(crate) fn plan_root_supervisor_stage_at_locked( root: &Path, + project_lock: &ProjectWriteLock, agent_id: &str, run_id: &str, ) -> Result { + if !project_lock.guards_project_root(root)? { + return Err("plan Supervisor 阶段判定缺少当前项目写锁".to_string()); + } if read_game_creator_agent_runtime_goal_contract_at(root, agent_id, run_id)?.is_none() { return Ok(PlanRootSupervisorStage::GoalContract); } let delegated = list_static_delegate_deliveries_at(root)? .into_iter() .any(|delivery| delivery.parent_agent_id == agent_id && delivery.parent_run_id == run_id); - Ok(if delegated { - PlanRootSupervisorStage::Delegated - } else { - PlanRootSupervisorStage::Delegate - }) + if !delegated { + return Ok(PlanRootSupervisorStage::Delegate); + } + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && plan_root_supervisor_acceptance_evidence_required_locked(root, project_lock, run_id)? + { + return Ok(PlanRootSupervisorStage::AwaitingAcceptanceEvidence); + } + Ok(PlanRootSupervisorStage::Delegated) +} + +/// 供未持有项目写锁的调用方使用的阶段判定入口。 +pub(crate) fn plan_root_supervisor_stage_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.supervisor-stage", + )?; + plan_root_supervisor_stage_at_locked(root, &_lock, agent_id, run_id) } pub(crate) fn agent_runtime_native_executable_tools() -> Vec<&'static str> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index babb4ba92..2875c99c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -668,7 +668,7 @@ pub(super) const AGENT_RUNTIME_AUTONOMOUS_MANIFEST_DAG_WAIT_LIVENESS_ERROR_PREFI pub(super) const AGENT_RUNTIME_AUTONOMOUS_RESPONSE_PLAN_LIVENESS_ERROR_PREFIX: &str = "自主构建专业 Agent 已给出结论但结构化计划仍未完成"; pub(super) const AGENT_RUNTIME_AUTONOMOUS_TRUNCATED_SCAFFOLD_ERROR_PREFIX: &str = - "自主构建完整写入游戏入口(index.html 或 game/index.html)时必须提交闭合且可运行的 HTML"; + "自主构建完整写入 game/index.html 时必须提交闭合且可运行的 HTML"; pub(super) const AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID: &str = "quality-review"; pub(super) const AGENT_RUNTIME_SUPERVISOR_INITIAL_COLLABORATION_LIVENESS_ERROR_PREFIX: &str = "Project Supervisor 首批协作在首个 planning 窗口内未建立"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs index 7cb0b3801..fc5d9579a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs @@ -393,7 +393,11 @@ pub(in crate::agent) fn resume_game_creator_agent_finalization_at( } if journal.status == AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED && !assistant_exists { let current_revision = read_game_creator_agent_runtime_project_revision(root)?; - let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) { + // 与 `finish_game_creator_agent_background_runtime_turn_with_checkpoint_at` + // 同一判据:澄清信封是挂起等用户答,不是交付收束,用计划完成度拦它会死锁。 + let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) + .filter(|_| !response_is_static_delegate_user_input_envelope(&journal.response)) + { Some(blocker) } else if let Some(blocker) = plan_gdd_completion_blocker_at_locked(root, &journal.agent_id, &journal.run_id) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index c841465d3..379b102c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -208,15 +208,10 @@ pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str) matches!(kind.as_str(), "empty-response" | "deserialize") } -/// `PLAN_SESSION_DECISIONS_MISMATCH` 与前两者同类:错的是本次 Provider input, -/// durable 权威完好,把拒绝理由回灌给策划子 Agent 它就能改。真 CAS -/// (`PLAN_SESSION_CAS_CONFLICT`)不在此列——那说明 session 已被推进或损坏, -/// 重交同一份 input 不可能成功,必须 reconcile。 +/// 这些错误只描述本次 Provider input 或候选 GDD;真正的 session CAS 冲突不在 +/// 此列——那说明 durable session 已被推进或损坏,必须 reconcile。 fn plan_submit_error_is_business_rejection(error: &PlanningStorageError) -> bool { - matches!( - error.code(), - "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT" | "PLAN_SESSION_DECISIONS_MISMATCH" - ) + matches!(error.code(), "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT") } /// A malformed Fast GDD is useful feedback for the planning child, but it @@ -255,6 +250,39 @@ fn finish_plan_submit_business_rejection_limit_at( /// Runtime state 上,进程重启不能把一次活锁洗成新的无限 Provider 开销。 const AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT: u32 = 4; +/// 最终回复被收束门禁拦下后 run 会原地续跑重试。多数 blocker 是模型自己能解的 +/// (补动作、补证据、重新规划),所以这里的额度比上面两个宽得多;它拦的是另一 +/// 类:模型根本无法满足的 blocker。那种情况下每一轮都是同一个请求换来同一个拒绝, +/// 没有任何计数器会累加——空转闸只认裸 `update_agent_plan`,而这里模型每轮都在 +/// 认真调 `respond_to_user`。实测一条生产 run 因此空转 65 轮直到人工介入。 +const AGENT_RUNTIME_STALE_FINALIZATION_LIMIT: u32 = 32; + +fn stale_finalization_limit_reached(rounds: u32) -> bool { + rounds >= AGENT_RUNTIME_STALE_FINALIZATION_LIMIT +} + +fn next_stale_finalization_rounds(current: u32) -> (u32, bool) { + let next = current.saturating_add(1); + (next, stale_finalization_limit_reached(next)) +} + +fn finish_stale_finalization_limit_at( + root: &Path, + runtime: &AgentRuntimeState, +) -> Result { + let error = format!( + "最终回复连续 {} 轮被收束门禁拦下,已停止自动续跑;请检查最后一次 blocker observation 后重新发起本轮任务。", + AGENT_RUNTIME_STALE_FINALIZATION_LIMIT + ); + let failed = fail_game_creator_agent_runtime_turn_at(root, runtime.clone(), &error)?; + let _ = append_game_creator_agent_background_task_failed_audit( + root, + &failed, + AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_STALE_FINALIZATION_LIMIT, + ); + Ok(AgentBackgroundTaskOutcome::Finished) +} + /// 纯只读工具不算「推进」。 /// /// 空转计数只在**裸 `update_agent_plan` 且步骤没有真实变化**时累加,早期实现却让 @@ -350,6 +378,22 @@ mod plan_update_idle_guard_threshold_tests { assert!(first_repair_round < AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT); } + /// 最终回复重试额度是 runaway 兜底,不是主判据:它必须留在两道软闸之上, + /// 让「摘掉 update_agent_plan 逼它调真动作」和空转闸先有机会自愈。调到软闸 + /// 以下,兜底就会抢在自愈之前把正常 run 打断。 + #[test] + fn the_stale_finalization_backstop_sits_above_the_self_healing_guards() { + assert!(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT > AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT); + assert!(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT > PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT); + assert!(!stale_finalization_limit_reached( + AGENT_RUNTIME_STALE_FINALIZATION_LIMIT - 1 + )); + assert_eq!( + next_stale_finalization_rounds(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT - 1), + (AGENT_RUNTIME_STALE_FINALIZATION_LIMIT, true) + ); + } + #[test] fn idle_limit_is_reached_only_at_the_configured_round() { assert!(!plan_update_idle_limit_reached(0)); @@ -459,6 +503,8 @@ const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_PLAN_SUBMIT_REJECTION_LIMIT: &str = "plan-submit-validation-retries-exhausted"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_PLAN_UPDATE_IDLE_LIMIT: &str = "plan-update-idle-rounds-exhausted"; +const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_STALE_FINALIZATION_LIMIT: &str = + "stale-finalization-rounds-exhausted"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_BUDGET: &str = "loop-budget-exhausted"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINAL_REPLY: &str = "final-reply-failed"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINALIZATION: &str = "finalization-failed"; @@ -531,6 +577,20 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( }; } + // 同上:计数随上一轮的 blocker 一起落盘,重启不能把第 N 次被拦洗成新一轮。 + if stale_finalization_limit_reached(runtime.stale_finalization_rounds) { + return match finish_stale_finalization_limit_at(&root, &runtime) { + Ok(outcome) => outcome, + Err(error) => fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("收束已耗尽的最终回复重试失败:{error}"), + ), + }; + } + if continuation.applied_steer_cursor < runtime.applied_steer_cursor { return fail_game_creator_agent_background_context_at( &root, @@ -681,8 +741,11 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( if let Some(blocker) = static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) { + // park 只在真有外部事件可等时才对。用户修订待办不是外部事件——那条回执 + // 只能来自本 run 自己创建的修订委派,park 住就是等自己。下面 1700 行附近 + // 的 `user_revision_pending` 分支才是它该去的地方。 let waits_for_delivery = blocker.detail.as_deref().is_some_and(|detail| { - static_delegate_barrier_has_waiting_deliveries(detail) + static_delegate_barrier_has_external_wait(detail) || static_delegate_barrier_requires_user_input(detail) }); if waits_for_delivery { @@ -1559,6 +1622,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( { // 本轮至少有一个能推进 durable 状态的动作,计划没有空转。 runtime.plan_update_idle_rounds = 0; + // 同一个判据也给最终回复重试额度解锁:真实推进之后再被拦,是新的一 + // 轮尝试,不该继承上一段死循环的计数。 + runtime.stale_finalization_rounds = 0; } if plan.actions.is_empty() { // blocked 的 plan_gdd blocker 有三种截然不同的继续推进态,phase 与 @@ -1776,7 +1842,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( AgentBackgroundTaskOutcome::WaitingForIsolatedJoin, )) } else if observation.tool == "runtime.delegate_receipts" - && (static_delegate_barrier_has_waiting_deliveries(detail) + && (static_delegate_barrier_has_external_wait(detail) || static_delegate_barrier_requires_user_input(detail)) { Some(( @@ -3844,6 +3910,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( AgentBackgroundTaskOutcome::Finished } Ok(AgentBackgroundFinalizationOutcome::Stale(blocker)) => { + let (stale_rounds, exhausted) = + next_stale_finalization_rounds(runtime.stale_finalization_rounds); + runtime.stale_finalization_rounds = stale_rounds; if let Err(error) = provider_handoff::remove_at(&root, &agent_id, &runtime.run_id) { return fail_game_creator_agent_background_context_at( &root, @@ -3873,6 +3942,20 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( ); } }; + // 计数已随 blocker 一起落盘,这里才收束:让最后一次拒绝的 observation + // 留在续跑上下文里,失败原因指得回具体 blocker 而不是一句「超限」。 + if exhausted { + return match finish_stale_finalization_limit_at(&root, &runtime) { + Ok(outcome) => outcome, + Err(error) => fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("收束已耗尽的最终回复重试失败:{error}"), + ), + }; + } AgentBackgroundTaskOutcome::ContinueSameRun { state: runtime, continuation, @@ -3900,9 +3983,9 @@ mod plan_envelope_repair_tests { use super::*; const TRUNCATED: &str = "AGC_NEEDS_USER_INPUT_V1 -{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",\"question\":\"当前要决定:?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"}]}"; +{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环形状\",\"question\":\"?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"}]}"; const COMPLETE: &str = "AGC_NEEDS_USER_INPUT_V1 -{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",\"question\":\"当前要决定:?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"},{\"label\":\"B\",\"description\":\"乙\"}]}]}"; +{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环形状\",\"question\":\"?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"},{\"label\":\"B\",\"description\":\"乙\"}]}]}"; /// 截断的信封必须在 run 内被认出来,否则它会随 final reply 逃逸成一条 /// needs-repair 委派,把返工额度和澄清轮次一起卷进去。 @@ -3956,7 +4039,7 @@ mod plan_envelope_repair_tests { fn a_degenerated_tail_no_longer_burns_a_repair_attempt() { let reply = concat!( "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"replay_progression","header":"第2轮·关键决定","question":"当前要决定:自由经营农场的长期目标采用哪种组合?","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施。"},{"label":"需要原型验证","description":"制作微型原型让目标玩家试玩两种目标结构。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, + r#"{"questions":[{"id":"replay_progression","header":"第2轮·当前要决定:自由经营农场的长期目标","question":"它决定玩家为何持续规划、赚钱与重玩,也控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施。"},{"label":"需要原型验证","description":"制作微型原型让目标玩家试玩两种目标结构。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, ); assert!(game_creator_agent_runtime_plan_envelope_parse_error( GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, @@ -4101,27 +4184,4 @@ mod plan_gdd_blocker_projection_tests { "版本上限由既有 lineage 决定,重试相同 Provider submit 不会改变它" ); } - - /// 台账逐项比对失败是本次 Provider input 写错,durable 权威完好,回灌理由后 - /// 策划子 Agent 能自行改稿;真 CAS 则说明 session 已被推进或损坏,重交同一份 - /// input 不可能成功。两者曾共用 `PLAN_SESSION_CAS_CONFLICT`,导致前者也被判成 - /// 硬阻断——实测中策划子 Agent 靠回灌连改三轮修好了形状层,紧接着撞上这一支 - /// 直接 needs-reconciliation,整条链路无产物收场。 - #[test] - fn session_ledger_mismatch_is_provider_feedback_but_a_real_cas_conflict_is_not() { - assert!( - plan_submit_error_is_business_rejection(&PlanningStorageError::new( - "PLAN_SESSION_DECISIONS_MISMATCH", - "submit input 未逐项匹配当前 planning session 决策摘要" - )), - "台账不匹配应回灌给 Provider 修正,受既有 5 次预算约束" - ); - assert!( - !plan_submit_error_is_business_rejection(&PlanningStorageError::new( - "PLAN_SESSION_CAS_CONFLICT", - "planning session 已被其它动作推进" - )), - "真 CAS 必须走 reconciliation,不得消耗 Provider 重试额度" - ); - } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 9db5f7eac..3ff8e559a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -519,7 +519,7 @@ fn start_game_creator_agent_background_task_with_link_in_session_lane_with_proje let error = redact_agent_runtime_project_paths(root, &error, 500); let failed_task = AgentRuntimeTaskRecord { status: "failed".to_string(), - phase: "planning-session-projection-failed".to_string(), + phase: AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED.to_string(), current_action: "Fast GDD session 未能安全绑定,后台任务未执行".to_string(), terminal_detail: Some(error.clone()), error: Some(error.clone()), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs index e4bf34b09..5b2f387d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs @@ -268,7 +268,13 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_finalization_journal &journal.plan_steps, journal.active_plan_step_index, )?; + // 澄清信封是本 run 挂起等用户答,不是交付收束:剩余步骤要等用户答复后的 + // continuation run 才做,在这里既不可能 completed,也不该被 Runtime 代填成 + // completed——那是伪造进度。只有真正宣告做完的最终回复才受这条不变量约束。 + // 这里放行的是「计划未完成」这一件事;快照自身的结构合法性仍由上面的 + // `validate_agent_runtime_structured_plan_snapshot` 逐项校验。 if journal.plan_revision > 0 + && !response_is_static_delegate_user_input_envelope(&journal.response) && (journal.active_plan_step_index.is_some() || journal .plan_steps diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs index 27c300f3d..691ddb02d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs @@ -74,6 +74,48 @@ fn approval_observation(receipt: &PlanGddApprovalV1) -> AgentRuntimeToolObservat } } +/// 审批卡上的「修改/退回」是用户说的话,落点和决策卡的答案一样:Supervisor 自己 +/// 的会话文件。`append_user_input_answer_message` 已经为澄清答案建立了这条通道, +/// 审批决定沿用它。没有这一步 Supervisor 只能从 delivery 的 +/// `contractStatus=user-revision-requested` 知道「用户要改」,读不到要改什么—— +/// playbook 第 6 条的「把用户原话完整附在 task 里」就没有原话可附,返工委派只能 +/// 写一句占位,子 Agent 于是自由发挥。 +fn append_plan_gdd_revision_message( + root: &Path, + receipt: &PlanGddApprovalV1, + receipt_gdd: &PlanGddV1, +) -> Result<(), String> { + let label = if receipt.action == "reject" { + "退回" + } else { + "修改" + }; + let supervisor_session_id = resolve_game_creator_agent_runtime_session_id_for_run_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &receipt_gdd.root_run_id, + )?; + append_local_conversation_message_for_session_idempotent_at( + root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + // receipt 的 rootRun 所属 Supervisor task 才是这条用户意见的历史归属; + // 不能在 recovery 重放时按当前 active session 重新路由到别的会话。 + Some(&supervisor_session_id), + LocalConversationMessage { + role: "user".to_string(), + content: format!( + "我对 Fast GDD v{} 的审批:{}。意见原文:\n{}", + receipt.version, + label, + receipt.comment.as_deref().unwrap_or_default() + ), + agent_id: None, + }, + &format!("plan-gdd-decision-{}", receipt.response_id), + ) + .map(|_| ()) +} + fn receipt_decision_input( gdd: &PlanGddV1, input: &DecidePlanGddInputV1, @@ -302,6 +344,85 @@ fn latest_plan_gdd_for_root<'a>(gdds: &'a [PlanGddV1], root_run_id: &str) -> Opt }) } +/// Return whether the plan-root Supervisor must collect the current GDD +/// acceptance evidence before it can dispatch another planning child. +/// +/// This is deliberately a read-only projection of the existing acceptance +/// gate. It does not create approval pending or mutate any planning sidecar; +/// the actual pending projection remains owned by +/// `ensure_plan_gdd_approval_pending_after_acceptance_locked` after a successful +/// `agent.acceptance_update`. The caller must hold the current project write +/// lock and pass that guard explicitly. +pub(crate) fn plan_root_supervisor_acceptance_evidence_required_locked( + root: &Path, + project_lock: &ProjectWriteLock, + run_id: &str, +) -> Result { + if !project_lock.guards_project_root(root)? { + return Err("plan Supervisor 阶段判定缺少当前项目写锁".to_string()); + } + if !crate::config::game_creator_planning_capability_enabled()? { + return Ok(false); + } + if run_id.trim().is_empty() { + return Ok(false); + } + let gdds = read_plan_gdd_chain_locked(root).map_err(|error| error.to_string())?; + let Some(gdd) = latest_plan_gdd_for_root(&gdds, run_id) else { + return Ok(false); + }; + let Some(global_latest) = gdds.last() else { + return Ok(false); + }; + if global_latest.gdd_id != gdd.gdd_id + || global_latest.version != gdd.version + || global_latest.fingerprint != gdd.fingerprint + { + return Ok(false); + } + validate_plan_gdd(gdd).map_err(|error| error.to_string())?; + + let approvals = read_plan_gdd_approvals_locked(root).map_err(|error| error.to_string())?; + validate_plan_gdd_approvals_against_gdds(&gdds, &approvals) + .map_err(|error| error.to_string())?; + if read_plan_gdd_approval_for_version_locked(root, gdd.version) + .map_err(|error| error.to_string())? + .is_some() + { + return Ok(false); + } + if let Some(pending) = + read_plan_gdd_approval_pending_locked(root).map_err(|error| error.to_string())? + { + if !pending_matches_gdd(&pending, gdd) { + return Err("plan Supervisor 阶段判定发现 approval pending identity 冲突".to_string()); + } + return Ok(false); + } + + let Some(session) = + read_plan_session_with_recovery_locked(root).map_err(|error| error.to_string())? + else { + return Ok(false); + }; + if !plan_gdd_session_matches_submission(&session, gdd) { + return Ok(false); + } + let Some(delivery) = read_static_delegate_delivery_at(root, &gdd.delegation_id)? else { + return Ok(false); + }; + if delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent + || delivery.terminal_status.as_deref() != Some("completed") + { + return Ok(false); + } + + Ok(matches!( + plan_fast_gdd_acceptance_status_at_locked(root, gdd)?, + PlanFastGddAcceptanceStatus::NeedsEvidence + )) +} + pub(crate) fn ensure_plan_gdd_approval_pending_after_acceptance_locked( root: &Path, agent_id: &str, @@ -1019,89 +1140,96 @@ fn project_receipt_locked( let pending_observation = approval_observation(receipt); let mut approval_pending_cleanup_eligible = false; - let approval_pending = match read_plan_gdd_approval_pending_locked(root) { - Ok(value) => value, - Err(error) => { - note_plan_gdd_projection_gap( - root, - receipt, - "approval-pending-read", - &error.to_string(), - ); - recovery_pending = true; - None - } - }; - match approval_pending { - Some(mut pending) => { - if !pending_identity_matches_gdd(&pending, receipt_gdd) { + // Approval pending is a singleton projection for the latest GDD, not a + // per-receipt projection. A historical receipt must still repair its own + // index/Markdown/audit/runtime anchors, but it must not compare the + // current pending card with its older GDD identity. After a revise/reject + // creates a newer GDD, that comparison is expected to differ. + if receipt.version == latest.version { + let approval_pending = match read_plan_gdd_approval_pending_locked(root) { + Ok(value) => value, + Err(error) => { note_plan_gdd_projection_gap( root, receipt, - "approval-pending-identity", - "approval pending 与 receipt GDD identity 不一致", + "approval-pending-read", + &error.to_string(), ); recovery_pending = true; - } else { - let expected_status = format!("observed_{}", receipt.action); - if !matches!(pending.status.as_str(), "awaiting_decision") - && pending.status != expected_status - { + None + } + }; + match approval_pending { + Some(mut pending) => { + if !pending_identity_matches_gdd(&pending, receipt_gdd) { note_plan_gdd_projection_gap( root, receipt, - "approval-pending-status", - &format!( - "approval pending status={} 既不是 awaiting_decision 也不是 {expected_status}", - pending.status - ), + "approval-pending-identity", + "approval pending 与 receipt GDD identity 不一致", ); recovery_pending = true; - // Do not remove a projection whose durable state belongs - // to another decision action. - approval_pending_cleanup_eligible = false; } else { - approval_pending_cleanup_eligible = true; - pending.status = format!("observed_{}", receipt.action); - pending.observation = Some(PlanGddApprovalObservationV1 { - tool: pending_observation.tool.clone(), - status: pending_observation.status.clone(), - summary: pending_observation.summary.clone(), - detail: pending_observation.detail.clone(), - }); - match plan_gdd_approval_pending_fingerprint(&pending) { - Ok(fingerprint) => { - pending.pending_fingerprint = fingerprint; - if let Err(error) = - write_plan_gdd_approval_pending_atomic_locked(&root, &pending) - { + let expected_status = format!("observed_{}", receipt.action); + if !matches!(pending.status.as_str(), "awaiting_decision") + && pending.status != expected_status + { + note_plan_gdd_projection_gap( + root, + receipt, + "approval-pending-status", + &format!( + "approval pending status={} 既不是 awaiting_decision 也不是 {expected_status}", + pending.status + ), + ); + recovery_pending = true; + // Do not remove a projection whose durable state belongs + // to another decision action. + approval_pending_cleanup_eligible = false; + } else { + approval_pending_cleanup_eligible = true; + pending.status = format!("observed_{}", receipt.action); + pending.observation = Some(PlanGddApprovalObservationV1 { + tool: pending_observation.tool.clone(), + status: pending_observation.status.clone(), + summary: pending_observation.summary.clone(), + detail: pending_observation.detail.clone(), + }); + match plan_gdd_approval_pending_fingerprint(&pending) { + Ok(fingerprint) => { + pending.pending_fingerprint = fingerprint; + if let Err(error) = + write_plan_gdd_approval_pending_atomic_locked(root, &pending) + { + note_plan_gdd_projection_gap( + root, + receipt, + "approval-pending-write", + &error.to_string(), + ); + recovery_pending = true; + } + } + Err(error) => { note_plan_gdd_projection_gap( root, receipt, - "approval-pending-write", + "approval-pending-fingerprint", &error.to_string(), ); recovery_pending = true; } } - Err(error) => { - note_plan_gdd_projection_gap( - root, - receipt, - "approval-pending-fingerprint", - &error.to_string(), - ); - recovery_pending = true; - } } } } + // The approval pending projection is allowed to be absent after the + // original submit anchors have durably consumed the terminal + // observation. The generic-anchor reconciliation below decides + // whether this is a normal post-consumption state or a recovery gap. + None => {} } - // The approval pending projection is allowed to be absent after the - // original submit anchors have durably consumed the terminal - // observation. The generic-anchor reconciliation below decides - // whether this is a normal post-consumption state or a recovery gap. - None => {} } let generic_submit_consumed = match project_generic_submit_observation_locked(root, receipt) { @@ -1149,15 +1277,32 @@ fn project_receipt_locked( note_plan_gdd_projection_gap(root, receipt, "delivery-revision-mark", &error); recovery_pending = true; } + if let Err(error) = append_plan_gdd_revision_message(root, receipt, receipt_gdd) { + note_plan_gdd_projection_gap(root, receipt, "delivery-revision-message", &error); + recovery_pending = true; + } } // A replay may target an older receipt after a newer GDD has already been // submitted. The receipt still repairs its own audit/observation, but it // must not try to roll the current session or delivery lineage backwards. - let session_points_to_receipt = match read_plan_session_with_recovery_locked(root) { - Ok(session) => session - .as_ref() - .and_then(|session| session.latest_submitted_ref.as_ref()) - .is_some_and(|reference| reference == &receipt_plan_ref(receipt)), + let session_projection_eligible = match read_plan_session_with_recovery_locked(root) { + Ok(session) => session.as_ref().is_some_and(|session| { + let receipt_ref_matches = session + .latest_submitted_ref + .as_ref() + .is_some_and(|reference| reference == &receipt_plan_ref(receipt)); + let decision_ref_matches = + session.last_decision_ref.as_ref().is_some_and(|reference| { + reference.version == receipt.version + && reference.response_id == receipt.response_id + && reference.action == receipt.action + && reference.receipt_fingerprint == receipt.receipt_fingerprint + }); + decision_ref_matches + || (receipt_ref_matches + && session.phase == "awaiting_gdd_approval" + && session.active_run_id.is_none()) + }), Err(error) => { note_plan_gdd_projection_gap(root, receipt, "plan-session-read", &error.to_string()); recovery_pending = true; @@ -1165,7 +1310,7 @@ fn project_receipt_locked( } }; let mut session_projection_ready = false; - if receipt.version == latest.version || session_points_to_receipt { + if session_projection_eligible { if let Err(error) = project_plan_session_locked(root, receipt_gdd, receipt) { note_plan_gdd_projection_gap(root, receipt, "plan-session-project", &error.to_string()); recovery_pending = true; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs index 4e6bf1374..e47c24f67 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs @@ -5,6 +5,13 @@ use uuid::Uuid; const PLAN_OPTION_A_PREFIX: char = 'A'; const PLAN_OPTION_B_PREFIX: char = 'B'; const PLAN_OPTION_PROTOTYPE_VALIDATION: &str = "需要原型验证"; +/// 策划决策卡恒为 A / B /「需要原型验证」三项,不是通用 `user.input_request` 协议的 +/// 2-3 个。role brief 早先照通用常量写成「2~3 个」,与本文件的硬校验和 brief 自己 +/// 下文的「固定提供三个选项」三方打架;模型照前者吐两项,整封信封在出卡时被拒、 +/// 回灌重试,白烧一个未推进回合,丢掉的还恰好是用户产生 `prototype_pending` 的唯一 +/// 入口。`project_planning_role_brief_states_the_parser_wire_shape_verbatim` 钉住 +/// brief 与这里同源。 +pub(crate) const PLAN_CLARIFICATION_OPTION_COUNT: usize = 3; const PLAN_QUESTION_PREFIX: &str = "当前要决定:"; #[derive(Clone, Debug, Eq, PartialEq)] @@ -18,6 +25,25 @@ fn plan_coordinator_error(kind: &str, detail: impl AsRef) -> String { format!("{kind}: {}", detail.as_ref()) } +fn validate_plan_continuation_parent<'a>( + latest_delegation_id: &str, + delivery: &'a StaticDelegateDeliveryRecord, +) -> Result<&'a str, String> { + let original_id = delivery.repair_of_delegation_id.as_deref().ok_or_else(|| { + plan_coordinator_error( + "PLAN_ACTIVE_RUN_EXISTS", + "已有 planning session 时不能创建第二条根 delegation", + ) + })?; + if latest_delegation_id != original_id { + return Err(plan_coordinator_error( + "PLAN_NEEDS_RECONCILIATION", + "planning continuation 必须直接继承当前 session 的 latest delegation", + )); + } + Ok(original_id) +} + fn plan_session_successor_base(previous: &PlanSessionV1) -> Result { let mut next = previous.clone(); next.session_revision = previous.session_revision.checked_add(1).ok_or_else(|| { @@ -110,14 +136,40 @@ fn exact_plan_child_identity_at( Ok(Some((binding, delivery))) } -fn plan_question_topic(question: &AgentRuntimeUserInputQuestion) -> Result { - let remainder = question - .question - .strip_prefix(PLAN_QUESTION_PREFIX) +/// 剥掉 header 的 `第{round}轮·` 前缀,返回其后的正文。 +/// +/// 轮号本身由 Runtime 从委派谱系派生,模型只是照着任务正文抄;这里逐字核对它,写错就 +/// 拒收——否则卡片标题会和 `GddApprovalCard` 那个「第 N 轮 / 共 3 轮」自相矛盾。 +fn plan_header_body(header: &str, round: u32) -> Option<&str> { + let rest = header.trim_start().strip_prefix('第')?.trim_start(); + let digits = rest + .chars() + .take_while(char::is_ascii_digit) + .collect::(); + if digits.parse::().ok()? != round { + return None; + } + let rest = rest[digits.len()..].trim_start().strip_prefix('轮')?.trim(); + // 原型模板写作 `第 N 轮 · 当前要决定:…`,中文语境下模型高频吐出 `·`/`:`/`:`/`-` + // 几种分隔符;不在集合里的后果是整封信封被拒、白吃一个未推进回合。 + let rest = rest.strip_prefix(&PLAN_OPTION_LABEL_DELIMITERS[..])?; + Some(rest.trim_start()) +} + +/// 决定台账的 `topic` 取自 header。 +/// +/// 原型(`design_agent.py:1841`)直接把整条 header 当 topic;这里只是再剥掉 `第N轮·` 和 +/// 「当前要决定:」两层固定前缀,落进台账的是主题本身。 +fn plan_question_topic( + question: &AgentRuntimeUserInputQuestion, + round: u32, +) -> Result { + let remainder = plan_header_body(&question.header, round) + .and_then(|body| body.strip_prefix(PLAN_QUESTION_PREFIX)) .ok_or_else(|| { plan_coordinator_error( "PLAN_INVALID_CLARIFICATION", - "plan question 必须以“当前要决定:”开头", + format!("plan header 必须形如“第{round}轮·当前要决定:<主题>”"), ) })?; let topic = remainder @@ -129,7 +181,12 @@ fn plan_question_topic(question: &AgentRuntimeUserInputQuestion) -> Result bool { let Some(remainder) = label.strip_prefix(prefix).map(str::trim_start) else { @@ -154,6 +211,83 @@ fn plan_option_label_has_prefix(label: &str, prefix: char) -> bool { /// `user_freeform`。两边 state 同为 `confirmed`,状态机看不出异常——被污染的恰好是第 /// 23.9 节要立起来的那个字段。`planning_clarification_option_pick_survives_untrimmed_label` /// 钉的就是这条不变量。 +#[cfg(test)] +mod option_label_delimiter_tests { + use super::*; + + /// 分隔符集合只能放宽、不能收窄,且必须覆盖原型 `_OPTION_A_PATTERN` 的那一份。 + /// + /// 锁的是「集合里每一个都被接受」这条不变量,不是某个具体标点:少一个的后果不是 + /// 「模型换个写法」,而是一封完全合法的信封被判形状错误、回灌重试,白吃一个未推进 + /// 回合——`planning_clarification_accepts_fullwidth_colon_option_labels` 记的就是 + /// 全角冒号那一次。 + /// 逐字来自原型 `design_agent.py` 的 `^A\s*[·•・::..\-]`。这里**不能**改成遍历 + /// `PLAN_OPTION_LABEL_DELIMITERS` 本身——那样从集合里删掉一个,循环也跟着少测一个, + /// 断言恒真。 + const PROTOTYPE_DELIMITERS: [char; 8] = ['·', '•', '・', ':', ':', '.', '.', '-']; + + #[test] + fn every_delimiter_in_the_set_is_accepted_on_both_option_prefixes() { + for delimiter in PROTOTYPE_DELIMITERS { + for prefix in [PLAN_OPTION_A_PREFIX, PLAN_OPTION_B_PREFIX] { + let label = format!("{prefix}{delimiter}方案短语"); + assert!( + plan_option_label_has_prefix(&label, prefix), + "分隔符 {delimiter:?} 被拒:{label}" + ); + let spaced = format!("{prefix} {delimiter} 方案短语"); + assert!( + plan_option_label_has_prefix(&spaced, prefix), + "带空格写法被拒:{spaced}" + ); + } + } + assert!( + !plan_option_label_has_prefix("A方案短语", PLAN_OPTION_A_PREFIX), + "没有分隔符不能算合法 A 选项,否则 A/B 与自由文本会混" + ); + } +} + +#[cfg(test)] +mod planning_continuation_parent_tests { + use super::*; + + fn delivery(repair_of_delegation_id: Option<&str>) -> StaticDelegateDeliveryRecord { + new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "supervisor-session", + "supervisor-run", + "delegate-action", + "current-delivery", + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "planning-session", + "planning-run", + &[], + &[], + repair_of_delegation_id, + ) + } + + #[test] + fn continuation_must_extend_the_session_cursor() { + let continuation = delivery(Some("older-delivery")); + let error = validate_plan_continuation_parent("current-delivery", &continuation) + .expect_err("older delivery must not become the current planning branch"); + assert!(error.contains("latest delegation")); + } + + #[test] + fn continuation_accepts_the_current_session_cursor() { + let continuation = delivery(Some("current-delivery")); + assert_eq!( + validate_plan_continuation_parent("current-delivery", &continuation) + .expect("current delivery is a valid continuation"), + "current-delivery" + ); + } +} + fn plan_option_label_matches_answer(label: &str, normalized_answer: &str) -> bool { label == normalized_answer } @@ -178,14 +312,7 @@ pub(crate) fn validate_exact_plan_clarification_question( "plan questionId 必须是最多 32 个 ASCII 字符且不能映射为 initial-request", )); } - let expected_header = format!("第{round}轮·关键决定"); - if question.header != expected_header { - return Err(plan_coordinator_error( - "PLAN_INVALID_CLARIFICATION", - format!("plan question header 必须精确等于 {expected_header}"), - )); - } - let valid_shape = question.options.len() == 3 + let valid_shape = question.options.len() == PLAN_CLARIFICATION_OPTION_COUNT && plan_option_label_has_prefix(&question.options[0].label, PLAN_OPTION_A_PREFIX) && plan_option_label_has_prefix(&question.options[1].label, PLAN_OPTION_B_PREFIX) && question.options[2].label == PLAN_OPTION_PROTOTYPE_VALIDATION; @@ -195,7 +322,9 @@ pub(crate) fn validate_exact_plan_clarification_question( "plan question 必须恰好提供 A、B、需要原型验证三个选项", )); } - plan_question_topic(question)?; + // header 的定形连同轮号一起在这里兜底:`plan_question_topic` 要求它形如 + // `第{round}轮·当前要决定:<主题>`,并把主题本身取出来给决定台账。 + plan_question_topic(question, round)?; Ok(()) } @@ -210,7 +339,7 @@ fn build_plan_clarification_decision_projection( let normalized_answer = normalize_plan_text(&answer.answer, "plan answer", 1, 400) .map_err(|error| error.to_string())?; let question = &answer.question; - let topic = plan_question_topic(question)?; + let topic = plan_question_topic(question, round)?; let decision_id = question.id.replace('_', "-"); let (state, answer_source) = if plan_option_label_matches_answer(&question.options[0].label, &normalized_answer) @@ -513,12 +642,13 @@ pub(crate) fn ensure_plan_session_for_planning_child_task_at_locked( { return Ok(true); } - let original_id = delivery.repair_of_delegation_id.as_deref().ok_or_else(|| { - plan_coordinator_error( - "PLAN_ACTIVE_RUN_EXISTS", - "已有 planning session 时不能创建第二条根 delegation", - ) - })?; + // `latest_delegation_id` is the planning session's single continuation + // cursor. A new child must extend that cursor directly; otherwise a + // Supervisor can select an older claimed delivery and make an unrelated + // branch look like the current session. Keep this check here, after the + // exact-task replay fast path above, so replaying an already projected + // child remains idempotent. + let original_id = validate_plan_continuation_parent(&previous.latest_delegation_id, &delivery)?; let deliveries = list_static_delegate_deliveries_at(root)?; if static_delegate_lineage_contains_unknown_contract_status( &deliveries, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs index a7f483afe..fba95f5d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs @@ -440,7 +440,10 @@ fn validate_decision_state(value: &str) -> Result<(), PlanningStorageError> { } fn validate_answer_source(value: &str) -> Result<(), PlanningStorageError> { - if matches!(value, "user_freeform" | "user_option" | "default") { + if matches!( + value, + "user_freeform" | "user_option" | "user_revision" | "default" + ) { Ok(()) } else { Err(invalid(format!("未知 answerSource:{value}"))) @@ -882,31 +885,38 @@ fn validate_decisions( if decision.round > 3 { return Err(invalid(format!("decisions[{index}].round 不能超过 3"))); } - // round=0 表示这条决定从未向用户提问过,因此它不能声称任何用户权威: - // answerSource 必须是 default。但它可以落在两种状态上——由 Agent 按默认 - // 建议填写(default_pending),或者 Agent 判定这项会实质影响首个可玩闭环、 - // 不该由它替用户拍板,需要一个 30~90 分钟微型原型来验证 - // (prototype_pending,并配同 id 的 prototypeValidationItems 项)。 - // - // 早期实现把 round=0 钉死成 default_pending。于是用户一次把需求说全、 - // 走 0 轮直出时,全部决定都是 round=0,没有任何决定可能成为 - // prototype_pending;而下面的双射又要求验证项逐项对应 prototype_pending - // 决定,结果是首次 plan.submit_gdd 必被预检拒收,且这份稿子永远不可能 - // 带上原型验证项。把一项未经验证的风险标成「默认,待确认」是在说谎: - // 那不是一个默认值,那是一个没人验证过的假设。 + if decision.answer_source == "user_revision" && decision.round != 0 { + return Err(invalid(format!( + "decisions[{index}] 的 user_revision 必须使用 round=0" + ))); + } + // round=0 不属于澄清轮:默认建议使用 default,审批修改使用 + // user_revision。两者都可以标记为 prototype_pending;用户明确修改的 + // 决定则可以标记 confirmed。 if decision.round == 0 && decision.id != "initial-request" { - if decision.answer_source != "default" { - return Err(invalid(format!( - "decisions[{index}] round=0 未经提问,answerSource 只能是 default" - ))); - } - if !matches!( - decision.state.as_str(), - "default_pending" | "prototype_pending" - ) { - return Err(invalid(format!( - "decisions[{index}] round=0 只能是 default_pending 或 prototype_pending" - ))); + match decision.answer_source.as_str() { + "default" + if matches!( + decision.state.as_str(), + "default_pending" | "prototype_pending" + ) => {} + "user_revision" + if matches!(decision.state.as_str(), "confirmed" | "prototype_pending") => {} + "default" => { + return Err(invalid(format!( + "decisions[{index}] round=0 的 default 只能是 default_pending 或 prototype_pending" + ))); + } + "user_revision" => { + return Err(invalid(format!( + "decisions[{index}] round=0 的 user_revision 只能是 confirmed 或 prototype_pending" + ))); + } + _ => { + return Err(invalid(format!( + "decisions[{index}] round=0 的 answerSource 只能是 default 或 user_revision" + ))); + } } } validate_text( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs index e8dc24c0d..dfb10dbc6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs @@ -1100,108 +1100,6 @@ fn gdd_submit_identity_matches(gdd: &PlanGddV1, context: &PlanSubmitGddRuntimeCo .is_none_or(|approval_request_id| gdd.approval_request_id == approval_request_id) } -/// 决定台账的权威归属:Runtime 拥有**事实**(用户在第几轮、对着哪道题、原样说了 -/// 什么),策划子 Agent 拥有**判断**(这句话是不是构成对该题的取舍、该记成什么 -/// topic)。 -/// -/// 早期实现要求 submit input 的前缀与 `session.decisionsSummary` 六个字段逐项相等。 -/// 那六个字段没有一个是子 Agent 生产的,它只能从 Supervisor 转述的委派 task 里回抄; -/// 而权威台账从不下发给它,拒绝理由也不含差异。于是「回抄」这件零信息量的动作成了 -/// 唯一的提交前提,用户只要自由填写过一次,逐字复现就依赖一条没有机制保证的 LLM -/// 转述链,抄歪即在 5 次盲重试后硬失败。同一条相等约束还顺带禁掉了子 Agent 纠正 -/// 错误绑定的能力——答非所问被 Runtime 投影成 confirmed 之后,改一个字都过不了。 -/// -/// 现在只守真正要守的那一条:**不能声称用户确认过他没确认的东西**。 -fn submit_decisions_respect_session_authority( - session: &PlanSessionV1, - input: &PlanSubmitGddInputV1, -) -> bool { - // 1. 不得凭空造出用户拍板:任何 confirmed 且非默认来源的决定,都必须命中一条 - // 同 id 的 confirmed session 决定。 - let no_forged_confirmation = input.decisions.iter().all(|decision| { - if decision.state != "confirmed" || decision.answer_source == "default" { - return true; - } - session - .decisions_summary - .iter() - .any(|recorded| recorded.id == decision.id && recorded.state == "confirmed") - }); - // 2. 不得丢弃用户已作出的决定,也不得篡改用户亲自选择的「需要原型验证」。 - // confirmed 允许降级为 default_pending(子 Agent 判定该轮回答并未回答所问 - // 时的唯一出口),但不能凭空消失。 - let no_dropped_authority = session.decisions_summary.iter().all(|recorded| { - let Some(decision) = input - .decisions - .iter() - .find(|decision| decision.id == recorded.id) - else { - return false; - }; - match recorded.state.as_str() { - "prototype_pending" => decision.state == "prototype_pending", - "confirmed" => matches!(decision.state.as_str(), "confirmed" | "default_pending"), - _ => true, - } - }); - // 3. Runtime 生成的原型验证项必须都在,内容由 `apply_plan_session_authority_to_ - // submit_input` 覆盖,不比较;子 Agent 可以另加自己的项,由 `validate_decisions` - // 的「逐项对应全部 prototype_pending 决定」双射约束兜底。 - let no_dropped_prototype_items = session.prototype_validation_items.iter().all(|recorded| { - input - .prototype_validation_items - .iter() - .any(|item| item.id == recorded.id) - }); - no_forged_confirmation && no_dropped_authority && no_dropped_prototype_items -} - -/// 把 Runtime 拥有的字段直接覆盖进 submit input,而不是要求子 Agent 回抄。 -/// -/// 覆盖对象只有「仍然挂着用户权威」的条目:保持 confirmed 的、以及用户亲选的 -/// prototype_pending。子 Agent 判定为未答而降级成 default_pending 的条目,其 -/// answerSummary 描述的是它自己填的默认值,归它所有,不覆盖。`topic` 任何情况下 -/// 都不覆盖——按答案真实内容重新命名决定,正是子 Agent 纠正错误绑定的手段。 -/// -/// 覆盖必须发生在 durable action identity 重放比对之前,且只依赖 session 里 -/// 跨 submit 不变的 `decisionsSummary` / `prototypeValidationItems` -/// (`build_submit_session_successor` 原样克隆这两项),这样同一个 actionId 重放 -/// 时归一化结果稳定,重放比对不会因为覆盖而错判成 payload 不一致。 -fn apply_plan_session_authority_to_submit_input( - session: &PlanSessionV1, - input: &mut PlanSubmitGddInputV1, -) { - for decision in &mut input.decisions { - let Some(recorded) = session - .decisions_summary - .iter() - .find(|recorded| recorded.id == decision.id) - else { - continue; - }; - let carries_user_authority = match recorded.state.as_str() { - "confirmed" => decision.state == "confirmed", - "prototype_pending" => decision.state == "prototype_pending", - _ => false, - }; - if !carries_user_authority { - continue; - } - decision.answer_source = recorded.answer_source.clone(); - decision.round = recorded.round; - decision.answer_summary = recorded.answer_summary.clone(); - } - for item in &mut input.prototype_validation_items { - if let Some(recorded) = session - .prototype_validation_items - .iter() - .find(|recorded| recorded.id == item.id) - { - *item = recorded.clone(); - } - } -} - fn session_identity_matches_context( session: &PlanSessionV1, context: &PlanSubmitGddRuntimeContext, @@ -1247,7 +1145,6 @@ fn build_submit_session_successor( fn validate_current_session_cas( session: &PlanSessionV1, context: &PlanSubmitGddRuntimeContext, - input: &PlanSubmitGddInputV1, ) -> Result<(), PlanningStorageError> { if !session_identity_matches_context(session, context) { return Err(submit_error( @@ -1283,20 +1180,6 @@ fn validate_current_session_cas( "当前 planning session 仍有未决 GDD", )); } - // 这一支和上面三条 CAS 判据性质不同,因此不共用 `PLAN_SESSION_CAS_CONFLICT`。 - // 真 CAS(revision 溢出、session 已被其它动作推进、Runtime source - // revision/fingerprint 无效)说明 durable 权威变了或坏了,重交同一份 input 也 - // 没用,只能 reconcile;而台账逐项比对失败时权威完好,错的是本次 Provider - // input——策划子 Agent 把 session 决策摘要抄漏、抄错或多追加了一条非默认决定。 - // 这正是第 12 节划归「本次 Provider input」的那一类,应该走 rejected - // observation 回灌让它改,受既有 5 次预算约束,而不是硬阻断等人。 - // 不变量本身一个字没放松:不匹配照样拒,只是改了拒绝的后果。 - if !submit_decisions_respect_session_authority(session, input) { - return Err(submit_error( - "PLAN_SESSION_DECISIONS_MISMATCH", - "submit input 的决定台账越过了 planning session 的用户权威", - )); - } if session.latest_delegation_id != context.delegation_id { return Err(submit_error( "PLAN_SOURCE_PROFILE_MISMATCH", @@ -1306,6 +1189,34 @@ fn validate_current_session_cas( Ok(()) } +/// `user_revision` 只证明审批意见,不证明澄清。首次 collecting、澄清续跑和没有待处理 +/// 用户修订的普通质量返工 session 都没有 revise/reject `lastDecisionRef`;用户修订周期 +/// 内的 continuation(包括其质量返工)会把该引用带到新的 collecting successor 上。 +/// continuation 的直接父边由 planning coordinator 校验,replay 不走这里。 +fn validate_user_revision_requires_approval_decision( + session: &PlanSessionV1, + input: &PlanSubmitGddInputV1, +) -> Result<(), PlanningStorageError> { + if !input + .decisions + .iter() + .any(|decision| decision.answer_source == "user_revision") + { + return Ok(()); + } + if session + .last_decision_ref + .as_ref() + .is_some_and(|reference| matches!(reference.action.as_str(), "revise" | "reject")) + { + return Ok(()); + } + Err(submit_error( + "PLAN_INVALID_REQUEST", + "user_revision 只能用于当前 session 已有 revise/reject 审批决定的续跑提交", + )) +} + fn validate_durable_child_binding( root: &std::path::Path, context: &PlanSubmitGddRuntimeContext, @@ -1717,11 +1628,20 @@ fn project_submit_successors_locked( // successor. The only safe forward path is an exact source-session // snapshot (including the still-active child run) or an already // projected session pointing at this immutable ref. + // These judgements are deliberately the same set `validate_current_session_cas` + // already enforced at the submit gate. Do not narrow them with a + // `latest_submitted_ref.is_none()` style assertion: revision plus the + // recomputed `sessionFingerprint` (`validate_plan_session` rejects a session + // whose fingerprint does not hash its own content) already pin the session to + // the exact snapshot the submitter observed, so any extra field-shape check + // only re-encodes the obsolete "one submission per lineage" rule. A user + // revision round legitimately arrives carrying the previous version's + // `latestSubmittedRef`; refusing it strands a committed GDD behind a + // `recoveryPending` that no replay can clear. let source_session_matches_gdd = session_identity_matches_gdd && previous_session.session_revision == gdd.source_session_revision && previous_session.session_fingerprint == gdd.source_session_fingerprint && previous_session.active_run_id.as_deref() == Some(gdd.created_by_run_id.as_str()) - && previous_session.latest_submitted_ref.is_none() && matches!( previous_session.phase.as_str(), "collecting" | "revision_requested" @@ -1789,23 +1709,6 @@ pub(crate) fn execute_plan_submit_gdd( let session_read = read_plan_session_with_recovery_locked(root); let current_session = session_read.as_ref().ok().and_then(Option::as_ref); - // Runtime 拥有的决定字段在这里一次性覆盖进 input,之后的重放比对、CAS 与 GDD - // 构建全部使用归一化后的值。放在重放分支之前是必需的:`submit_payload_matches_gdd` - // 拿 input 和已落库 GDD 反推出的 input 比对,只有两侧都归一化过才等价。归一化 - // 只读 `decisionsSummary` / `prototypeValidationItems`,二者跨 submit successor - // 原样保留,所以同一 actionId 重放的结果稳定。session 读不出来时保持原样,把 - // session 错误留给下面既有的分支处置。 - let normalized_input; - let input = match current_session { - Some(session) => { - let mut owned = input.clone(); - apply_plan_session_authority_to_submit_input(session, &mut owned); - normalized_input = owned; - &normalized_input - } - None => input, - }; - // First resolve the durable action identity. This branch intentionally // runs before pending/version checks: replay must be idempotent even when a // previous attempt already advanced the session or projections. @@ -1889,7 +1792,8 @@ pub(crate) fn execute_plan_submit_gdd( )); }; validate_plan_session(current_session)?; - validate_current_session_cas(current_session, context, input)?; + validate_current_session_cas(current_session, context)?; + validate_user_revision_requires_approval_decision(current_session, input)?; let version = chain .last() .map(|latest| latest.version.saturating_add(1)) @@ -2157,9 +2061,8 @@ mod tests { submit_fixture_from(valid_input()) } - /// 与 `submit_fixture` 同构,但由调用方提供 input:durable session 的 - /// `decisionsSummary` / `prototypeValidationItems` 直接镜像它,于是可以构造出 - /// 「用户已在第 N 轮拍板」「用户亲选了需要原型验证」这类前置台账。 + /// 与 `submit_fixture` 同构,但由调用方提供 input,并用它初始化 session 的 + /// 当前决定快照,便于构造澄清后或审批修订后的提交场景。 fn submit_fixture_from( input: PlanSubmitGddInputV1, ) -> (PathBuf, PlanSubmitGddRuntimeContext, PlanSubmitGddInputV1) { @@ -3429,38 +3332,34 @@ mod tests { } #[test] - fn submit_rejects_an_extra_non_default_decision_not_present_in_session() { + fn submit_rejects_user_revision_without_revise_or_reject_decision() { let (root, context, mut input) = submit_fixture(); input.decisions.push(PlanSubmitDecision { id: "invented-confirmation".to_string(), - topic: "未提问决定".to_string(), + topic: "审批新增决定".to_string(), state: "confirmed".to_string(), - answer_source: "user_option".to_string(), - round: 1, - answer_summary: "伪造为用户已确认".to_string(), + answer_source: "user_revision".to_string(), + round: 0, + answer_summary: "用户在审批意见中明确提出".to_string(), }); let error = execute_plan_submit_gdd(&root, &context, &input) - .expect_err("a non-default decision outside the session prefix must fail"); - // 伪造用户确认照样被拒;只是错误码从 CAS 换成了可回灌的输入类, - // 让策划子 Agent 能按理由改稿而不是把整个 Agent 阻断到人工核对。 - assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH"); + .expect_err("first collecting submit cannot forge user_revision"); + assert_eq!(error.code(), "PLAN_INVALID_REQUEST"); + assert!(error.to_string().contains("revise/reject")); assert!(!root.join(".agent/planning/gdd.v1.json").exists()); cleanup_fixture(root); } - /// 用户答案原文归 Runtime 所有:子 Agent 抄歪了直接被覆盖回去,而不是把整条 - /// 提交拒掉。真 CAS(durable 权威已变)仍然是另一回事,必须区分开。 + /// 新版本的决定快照由本次提交负责,旧 session 不再覆盖其内容。 #[test] - fn a_rewritten_answer_summary_is_overwritten_while_a_stale_session_is_still_a_cas_conflict() { - // 抄错既有决定的正文(权威没变,错的是 input):落库的是权威原文。 + fn a_rewritten_answer_summary_is_preserved_while_a_stale_session_is_still_a_cas_conflict() { let (root, context, mut input) = submit_fixture(); - let authoritative = input.decisions[0].answer_summary.clone(); input.decisions[0].answer_summary.push_str("(被改写)"); execute_plan_submit_gdd(&root, &context, &input) - .expect("a rewritten answer summary is overwritten, not rejected"); + .expect("the current submit snapshot owns its decision text"); let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); - assert_eq!(chain[0].decisions[0].answer_summary, authoritative); + assert!(chain[0].decisions[0].answer_summary.ends_with("(被改写)")); cleanup_fixture(root); // 同一份合法 input,只把 session revision 弄陈旧(权威已被推进)。 @@ -3518,7 +3417,7 @@ mod tests { chain[0].decisions[1].topic, "重玩动力(用户实际回答的是这个)" ); - // 但答案原文仍然是 Runtime 的权威值。 + // 当前提交快照保留 Provider 生成的答案正文。 assert_eq!( chain[0].decisions[1].answer_summary, "不要那两个,我要玩家只能移动光源给守卫开路" @@ -3526,17 +3425,16 @@ mod tests { cleanup_fixture(root); } - /// 降级(confirmed → default_pending)是允许的安全方向;整条丢掉不行——那会让 - /// 用户已经作出的决定从 GDD 里凭空消失。 + /// 修订可以删除、重写或重新定义旧决定;Runtime 不把旧 session 快照当内容门禁。 #[test] - fn a_confirmed_decision_may_be_downgraded_but_never_dropped() { + fn a_revision_may_downgrade_or_drop_an_obsolete_decision() { let (root, context, mut input) = submit_fixture_from(clarified_input()); input.decisions[1].state = "default_pending".to_string(); input.decisions[1].answer_source = "default".to_string(); input.decisions[1].answer_summary = "按默认建议填写,等待用户确认".to_string(); - execute_plan_submit_gdd(&root, &context, &input).expect("downgrade is the safe direction"); + execute_plan_submit_gdd(&root, &context, &input).expect("revision may change a decision"); let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); - // 降级之后这条不再声称用户拍过板,正文归子 Agent 所有,不被覆盖。 + // 新快照按 Provider 提交内容保存。 assert_eq!(chain[0].decisions[1].state, "default_pending"); assert_eq!( chain[0].decisions[1].answer_summary, @@ -3546,21 +3444,19 @@ mod tests { let (root, context, mut input) = submit_fixture_from(clarified_input()); input.decisions.remove(1); - let error = execute_plan_submit_gdd(&root, &context, &input) - .expect_err("dropping a user decision must fail"); - assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH"); + execute_plan_submit_gdd(&root, &context, &input) + .expect("revision may remove an obsolete decision"); cleanup_fixture(root); } - /// 用户亲手选的「需要原型验证」不是子 Agent 可以改判的东西。 + /// 修订可以重新定义原型验证范围,但结构约束仍然有效。 #[test] fn a_user_picked_prototype_validation_cannot_be_rewritten_by_the_planning_child() { let (root, context, mut input) = submit_fixture_from(clarified_input()); input.decisions[2].state = "confirmed".to_string(); input.prototype_validation_items.clear(); - let error = execute_plan_submit_gdd(&root, &context, &input) - .expect_err("a user-picked prototype validation must survive"); - assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH"); + execute_plan_submit_gdd(&root, &context, &input) + .expect("revision may remove an obsolete prototype item"); cleanup_fixture(root); } @@ -3630,32 +3526,32 @@ mod tests { cleanup_fixture(root); } - /// round=0 放开的只是状态,不是权威:从未提问过的决定仍然不许声称用户拍过板, - /// 也不许挂上任何 user_* 来源。 + /// round=0 区分默认建议与审批修订来源;澄清来源仍不能伪装成 round=0。 #[test] - fn a_round_zero_decision_still_cannot_claim_any_user_authority() { + fn round_zero_accepts_user_revision_but_rejects_clarification_sources() { let mut confirmed = valid_input(); confirmed.decisions.push(PlanSubmitDecision { id: "invented".to_string(), - topic: "没问过却声称已确认".to_string(), + topic: "审批修改的决定".to_string(), state: "confirmed".to_string(), - answer_source: "default".to_string(), + answer_source: "user_revision".to_string(), round: 0, - answer_summary: "伪造".to_string(), + answer_summary: "用户在审批意见中明确修改".to_string(), }); - validate_plan_submit_gdd_input(&confirmed).expect_err("round=0 may not be confirmed"); + validate_plan_submit_gdd_input(&confirmed) + .expect("user_revision may be confirmed at round=0"); let mut sourced = valid_input(); sourced.decisions.push(PlanSubmitDecision { id: "invented".to_string(), - topic: "没问过却挂上用户来源".to_string(), + topic: "澄清来源不能伪装为 round=0".to_string(), state: "prototype_pending".to_string(), answer_source: "user_option".to_string(), round: 0, answer_summary: "伪造".to_string(), }); validate_plan_submit_gdd_input(&sourced) - .expect_err("round=0 may not carry a user answer source"); + .expect_err("round=0 may not carry a clarification answer source"); } #[test] @@ -4183,6 +4079,51 @@ mod tests { cleanup_fixture(root); } + /// 审批卡上的「修改/退回」意见必须原文落进 Supervisor 会话——那是 playbook + /// 第 6 条「把用户原话完整附在 task 里」唯一的原话来源。delivery 的 + /// `contractStatus=user-revision-requested` 只说明「用户要改」,不带内容, + /// Supervisor 拿不到原文就只能在返工委派里写一句占位。 + /// + /// 追加必须幂等:`reconcile_plan_gdd_approval_projections_locked` 每次 hydrate + /// 都会为全部回执重跑 `project_receipt_locked`。 + #[test] + fn a_revision_comment_reaches_the_supervisor_conversation_once() { + let (root, gdd, _root_runtime) = acceptance_gate_fixture(true); + create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + decide_plan_gdd_at( + &root, + &approval_input( + &gdd, + "revise", + "gdd-response-00000000-0000-4000-8000-000000000050", + Some("把游戏名称改成日本语".to_string()), + ), + ) + .expect("commit revise receipt"); + let supervisor_messages = || { + read_local_conversation_for_session_at( + &root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + None, + ) + .expect("read supervisor conversation") + .messages + }; + let after_decision = supervisor_messages(); + assert!( + after_decision.iter().any(|message| message.role == "user" + && message.content.contains("把游戏名称改成日本语")), + "用户修改意见必须原文进入 Supervisor 会话" + ); + reconcile_plan_gdd_approval_projections_locked(&root).expect("replay receipt projections"); + assert_eq!( + supervisor_messages().len(), + after_decision.len(), + "投影重放不得重复追加同一条审批意见" + ); + cleanup_fixture(root); + } + /// reject 之后能不能在同一 lineage 重做,**不由提交门的 phase 判据决定**。 /// /// 提交门要求 session 的 activeRunId 等于当前策划子 run,而 schema 不变量禁止 @@ -4238,8 +4179,41 @@ mod tests { let mut next_context = context.clone(); next_context.source_session_revision = continuation.session_revision; next_context.source_session_fingerprint = continuation.session_fingerprint.clone(); - validate_current_session_cas(&continuation, &next_context, &input) + validate_current_session_cas(&continuation, &next_context) .expect("reject 之后的 continuation 必须能提交同一 lineage 的下一版本"); + + // 提交闸放行还不够:投影守卫必须认同一条 continuation。这条 session 必然带着 + // v1 的 latestSubmittedRef 和 reject 的 lastDecisionRef,投影守卫若据此判它不是 + // 合法起点,v2 就会越过提交点却收不了口,留下一个任何重放都清不掉的 + // recoveryPending。 + write_plan_session_atomic_locked(&root, &continuation).expect("write continuation session"); + next_context.action_id = "action-89abcdef0123456789abcdef".to_string(); + next_context.action_fingerprint = "4".repeat(64); + next_context.approval_request_id = + Some("gdd-approval-00000000-0000-4000-8000-000000000041".to_string()); + let mut revised_input = input.clone(); + revised_input.game.title = "审批修订后的标题".to_string(); + revised_input.decisions.push(PlanSubmitDecision { + id: "approval-scope".to_string(), + topic: "审批修改范围".to_string(), + state: "confirmed".to_string(), + answer_source: "user_revision".to_string(), + round: 0, + answer_summary: "用户要求采用新的首版范围".to_string(), + }); + let resubmit = execute_plan_submit_gdd(&root, &next_context, &revised_input) + .expect("continuation 提交 v2"); + assert_eq!(resubmit.gdd_ref.version, 2); + let chain = read_plan_gdd_chain(&root).expect("read revised GDD chain"); + assert_eq!(chain[1].game.title, "审批修订后的标题"); + assert_eq!( + chain[1].decisions.last().unwrap().answer_source, + "user_revision" + ); + assert!( + !resubmit.recovery_pending, + "提交闸放行的 continuation,投影守卫也必须放行" + ); cleanup_fixture(root); } @@ -4650,6 +4624,45 @@ mod tests { cleanup_fixture(root); } + #[test] + fn m1c2a_unapproved_gdd_requires_acceptance_evidence_before_delegate() { + let (root, _gdd, root_runtime) = acceptance_gate_fixture(true); + + assert_eq!( + plan_root_supervisor_stage_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_runtime.run_id, + ) + .expect("classify plan root stage"), + PlanRootSupervisorStage::AwaitingAcceptanceEvidence + ); + let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.plan-root-stage-locked", + ) + .expect("acquire plan root stage lock"); + assert_eq!( + plan_root_supervisor_stage_at_locked( + &root, + &project_lock, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_runtime.run_id, + ) + .expect("classify locked plan root stage"), + PlanRootSupervisorStage::AwaitingAcceptanceEvidence + ); + drop(project_lock); + assert_eq!( + agent_runtime_plan_root_supervisor_tools_for_stage( + PlanRootSupervisorStage::AwaitingAcceptanceEvidence + ), + &["file.read", "agent.acceptance_update", "agent.run_status"] + ); + + cleanup_fixture(root); + } + #[test] fn m1c2a_failed_acceptance_requires_claim_before_repair_dispatch() { let (root, gdd, root_runtime) = acceptance_gate_fixture(false); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs index 1e061353a..7d7ebc0f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs @@ -460,6 +460,148 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_success_handoff ) } +const PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT: &str = + "diagnostics/provider-reconciliation"; +const PROVIDER_RECONCILIATION_DIAGNOSTIC_MAX_BYTES: usize = 1024 * 1024; + +/// Persist the raw successful Provider response only in the application +/// private data directory. Project state keeps the safe summary below; this +/// sidecar is diagnostic-only and is never consulted by recovery/retry logic. +pub(in crate::agent) fn write_provider_reconciliation_diagnostic_at( + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, + response: &platform_llm::LlmRunResponse, + error: &str, +) -> Result { + let config_dir = game_creator_runtime_config_dir() + .ok_or_else(|| "Runtime config dir 未初始化,无法写入本地 Provider 诊断".to_string())?; + write_provider_reconciliation_diagnostic_in_dir( + &config_dir, + snapshot, + request_id, + response, + error, + ) +} + +fn write_provider_reconciliation_diagnostic_in_dir( + config_dir: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, + response: &platform_llm::LlmRunResponse, + error: &str, +) -> Result { + let project_key = format!("{:x}", Sha256::digest(snapshot.project_id.as_bytes())); + let request_key = format!("{:x}", Sha256::digest(request_id.as_bytes())); + let directory = config_dir + .join(PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT) + .join(&project_key); + fs::create_dir_all(&directory) + .map_err(|error| format!("创建本地 Provider 诊断目录失败:{error}"))?; + let relative_path = format!( + "{PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT}/{project_key}/{request_key}.json" + ); + let path = directory.join(format!("{request_key}.json")); + if let Ok(metadata) = fs::symlink_metadata(&path) { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("本地 Provider 诊断目标必须是普通文件".to_string()); + } + return Ok(relative_path); + } + let diagnostic = serde_json::json!({ + "schemaVersion": "provider-reconciliation-diagnostic.v1", + "identity": { + "projectId": snapshot.project_id.clone(), + "agentId": snapshot.agent_id.clone(), + "taskId": snapshot.task_id.clone(), + "sessionId": snapshot.session_id.clone(), + "runId": snapshot.run_id.clone(), + "source": snapshot.source.clone(), + "requestKind": snapshot.request_kind.clone(), + "requestSlot": snapshot.request_slot.clone(), + "requestId": request_id, + "appliedSteerCursor": snapshot.applied_steer_cursor, + }, + "provider": { + "provider": format!("{:?}", response.provider), + "model": response.model.clone(), + "responseId": response.response_id.clone(), + "finishReason": response.finish_reason.clone(), + "usage": response.usage.clone(), + }, + "failure": { + "error": error, + }, + "response": { + "text": response.text.clone(), + "toolCalls": response.tool_calls.iter().map(|call| serde_json::json!({ + "id": call.id.clone(), + "name": call.name.clone(), + "arguments": call.arguments.clone(), + })).collect::>(), + }, + }); + let mut content = serde_json::to_string_pretty(&diagnostic) + .map_err(|error| format!("序列化本地 Provider 诊断失败:{error}"))?; + content.push('\n'); + if content.len() > PROVIDER_RECONCILIATION_DIAGNOSTIC_MAX_BYTES { + return Err(format!( + "本地 Provider 诊断超过 {PROVIDER_RECONCILIATION_DIAGNOSTIC_MAX_BYTES} 字节" + )); + } + let temporary = path.with_file_name(format!(".{request_key}.tmp.{}", unix_timestamp_nanos())); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|error| format!("创建本地 Provider 诊断临时文件失败:{error}"))?; + if let Err(error) = file + .write_all(content.as_bytes()) + .and_then(|_| file.sync_data()) + { + let _ = fs::remove_file(&temporary); + return Err(format!("写入本地 Provider 诊断失败:{error}")); + } + drop(file); + if let Err(error) = fs::rename(&temporary, &path) { + let _ = fs::remove_file(&temporary); + return Err(format!("安装本地 Provider 诊断失败:{error}")); + } + Ok(relative_path) +} + +fn private_diagnostic_reference(error: &str) -> Option<&str> { + let reference = error.split_once(";localDiagnostic=")?.1.trim(); + let reference = reference.split(';').next()?.trim(); + if reference.starts_with(PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT) + && reference + .chars() + .all(|character| character.is_ascii_alphanumeric() || "/.-_".contains(character)) + { + Some(reference) + } else { + None + } +} + +fn attach_private_diagnostic_reference( + mut audit: serde_json::Value, + reference: Option, +) -> serde_json::Value { + if let (Some(reference), Some(audit)) = (reference, audit.as_object_mut()) { + audit.insert( + "localDiagnostic".to_string(), + serde_json::Value::String(reference), + ); + } + audit +} + #[cfg(test)] pub(crate) fn mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_for_test( root: &Path, @@ -505,6 +647,8 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di { return Err("孤立 Provider 请求与当前 Runtime 身份冲突".to_string()); } + let private_reference = + diagnostic.and_then(|(_, error)| private_diagnostic_reference(error).map(str::to_string)); let diagnostic = diagnostic.map(|(failure_kind, error)| { ( failure_kind, @@ -544,6 +688,11 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di detail }) .unwrap_or_else(|| format!("requestId={request_id}")); + let public_detail = if let Some(reference) = private_reference.as_deref() { + format!("{public_detail} · localDiagnostic={reference}") + } else { + public_detail + }; let event_detail = diagnostic .is_some() .then_some(public_detail.as_str()) @@ -625,6 +774,7 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di "requestSlot": snapshot.request_slot, }) }; + let audit = attach_private_diagnostic_reference(audit, private_reference); let _ = append_agent_db_record(root, audit); emit_game_creator_agent_runtime_update(root, &snapshot.agent_id); Ok(()) @@ -676,3 +826,76 @@ where ) .await } + +#[cfg(test)] +mod provider_reconciliation_diagnostic_tests { + use super::*; + + #[test] + fn private_diagnostic_keeps_raw_response_outside_project_state() { + let directory = tempfile::tempdir().expect("diagnostic directory"); + let snapshot = AgentRuntimeProviderRequestSnapshot { + project_id: "project-1".to_string(), + agent_id: "project-planning".to_string(), + task_id: "task-1".to_string(), + session_id: "session-1".to_string(), + run_id: "run-1".to_string(), + source: "agent-delegate".to_string(), + goal_id: None, + goal_revision: 0, + goal_snapshot_fingerprint: String::new(), + applied_steer_cursor: 0, + request_kind: "tool-plan".to_string(), + request_slot: "loop-1-repair-0".to_string(), + web_search_enabled: false, + allow_idle_context_compaction: false, + planning_session_binding: None, + }; + let response = platform_llm::LlmRunResponse { + provider: platform_llm::LlmProvider::OpenAiCompatible, + model: "test-model".to_string(), + text: "C:\\private\\response".to_string(), + finish_reason: Some("completed".to_string()), + response_id: Some("response-1".to_string()), + usage: None, + tool_calls: vec![platform_llm::LlmToolCall { + id: "call-1".to_string(), + name: "runtime_tool_plan_submit_gdd".to_string(), + arguments: "{\"path\":\"C:\\\\private\\\\argument\"}".to_string(), + }], + }; + let relative = write_provider_reconciliation_diagnostic_in_dir( + directory.path(), + &snapshot, + "provider-request-1", + &response, + "绝对路径 C:\\private\\error", + ) + .expect("write diagnostic"); + assert!(relative.starts_with("diagnostics/provider-reconciliation/")); + let persisted = + fs::read_to_string(directory.path().join(&relative)).expect("read diagnostic"); + let persisted: serde_json::Value = + serde_json::from_str(&persisted).expect("parse diagnostic"); + assert_eq!(persisted["response"]["text"], "C:\\private\\response"); + assert_eq!( + persisted["response"]["toolCalls"][0]["arguments"], + "{\"path\":\"C:\\\\private\\\\argument\"}" + ); + assert_eq!(persisted["failure"]["error"], "绝对路径 C:\\private\\error"); + } + + #[test] + fn private_diagnostic_reference_accepts_only_relative_reference() { + assert_eq!( + private_diagnostic_reference( + "失败;localDiagnostic=diagnostics/provider-reconciliation/p/r.json" + ), + Some("diagnostics/provider-reconciliation/p/r.json") + ); + assert_eq!( + private_diagnostic_reference("失败;localDiagnostic=C:\\secret.json"), + None + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs index 05c3d0431..27b7a3164 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs @@ -1406,7 +1406,7 @@ where attempt_snapshot.clone(), provider_request, |provider_request_id, response| { - if persist_handoff { + let handoff_result = if persist_handoff { let response = canonicalize_handoff_response(response); provider_handoff::write_at( root, @@ -1415,7 +1415,8 @@ where attempt, provider_request_id, &response, - )?; + ) + .map(|_| ()) } else if persist_tool_plan_handoff { tool_plan_handoff::write_at( root, @@ -1424,7 +1425,24 @@ where attempt, provider_request_id, response, - )?; + ) + .map(|_| ()) + } else { + Ok(()) + }; + if let Err(error) = handoff_result { + let error = match write_provider_reconciliation_diagnostic_at( + &attempt_snapshot, + provider_request_id, + response, + &error, + ) { + Ok(path) => format!("{error};localDiagnostic={path}"), + Err(diagnostic_error) => { + format!("{error};localDiagnosticWriteFailed={diagnostic_error}") + } + }; + return Err(error); } Ok(()) }, @@ -1694,7 +1712,12 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis &config_path_for_request, ) .map_err(platform_llm::LlmError::InvalidConfig)?; - client.run(request).await + request_game_creator_agent_runtime_provider_llm( + &client, + &llm_for_request, + request, + ) + .await } _ => unreachable!("agent mode is normalized"), } @@ -1704,6 +1727,18 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis .await } +async fn request_game_creator_agent_runtime_provider_llm( + client: &platform_llm::LlmClient, + llm: &GameCreatorLlmConfig, + request: platform_llm::LlmRunRequest, +) -> Result { + if llm.stream { + client.stream_run(request, |_| {}).await + } else { + client.run(request).await + } +} + pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transient_retries( root: &Path, provider_snapshot: &AgentRuntimeProviderRequestSnapshot, @@ -1748,9 +1783,8 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi request_game_creator_agent_codex_cli(request.clone()).await } GAME_CREATOR_AGENT_MODE_PROVIDER => { - client - .expect("provider mode constructs an HTTP client") - .run(request.clone()) + let client = client.expect("provider mode constructs an HTTP client"); + request_game_creator_agent_runtime_provider_llm(&client, llm, request.clone()) .await } _ => unreachable!("agent mode is normalized"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 6e29595eb..41c0db77c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -4,6 +4,12 @@ static AGENT_RUNTIME_EVENT_ID_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); pub(crate) const AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX: &str = "runtime-public-status-"; +/// task journal 只在**读**的时候校验 phase 白名单,写侧不校验。所以一个没登记的 +/// phase 落盘之后,整份 journal 从那一行起再也读不出来:`agent.run_status` 对该 +/// Agent 永久失败,父 run 只能瞎转到 needs-reconciliation。实测就是这么炸的。 +/// 让写方和白名单引用同一个常量,两边不可能再漂移。 +pub(crate) const AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED: &str = + "planning-session-projection-failed"; fn game_creator_agent_runtime_public_status_message_id( agent_id: &str, @@ -1204,7 +1210,13 @@ where )); } let current_revision = read_game_creator_agent_runtime_project_revision(root)?; - let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) { + // 澄清信封不是交付收束,是本 run 挂起等用户答。结构化计划完成度判据对它不可 + // 满足:想问用户就得先 respond_to_user,而计划里「按用户决定收敛并提交」那一 + // 步在用户答之前永远不可能 completed,于是问不出去、答不了、永远转。实测一条 + // 生产 run 因此空转 65 轮直到人工介入。其余判据仍然照常生效。 + let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) + .filter(|_| !response_is_static_delegate_user_input_envelope(response)) + { Some(blocker) } else if let Some(blocker) = game_creator_agent_goal_completion_blocker_at_locked(root, &state) { @@ -1603,6 +1615,7 @@ pub(crate) fn default_game_creator_agent_runtime_state( loop_iteration: 0, plan_submit_gdd_rejection_count: 0, plan_update_idle_rounds: 0, + stale_finalization_rounds: 0, max_loop_iterations: AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32, tool_action_budget: AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT as u32, plan_revision: 0, @@ -4164,6 +4177,7 @@ fn validate_game_creator_agent_runtime_task_status_phase( | "completion-contract-failed" | "conversation-write-failed" | "public-status-write-failed" + | AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED | "parent-terminal" | "parent-link-missing" ) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs index 71a033ff8..0580895f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs @@ -470,9 +470,9 @@ fn runtime_asset_import_string_array( || text.contains("://") || has_parent || should_skip_project_snapshot_path(text) - || text.split('/').any(|part| { - part.eq_ignore_ascii_case(".codex") || part.eq_ignore_ascii_case(".hermes") - }) + || text + .split('/') + .any(|part| part.eq_ignore_ascii_case(".codex")) || reject_sensitive_project_file_read(text).is_err() { return Err("localPaths 只能使用受控项目根内的项目相对图片路径".to_string()); @@ -774,7 +774,7 @@ mod asset_import_input_tests { assert!(runtime_asset_import_string_array(&private, "localPaths", 100).is_err()); for protected in [ "tools/.codex/hero.png", - "vendor/.hermes/hero.png", + "vendor/.codex/hero.png", "game/node_modules/hero.png", "secrets/hero.png", ] { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index 05355ba15..948eda19a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -214,12 +214,21 @@ pub(crate) fn observe_agent_runtime_agent_message( /// 平坦的 depth <= 1 门,那时「唯一返工轮」对澄清跳也成立;本仓库改成按谱系分类后 /// 把预算抬到 3,这句话就变成了假天花板——生产实测 4 次澄清续跑全部命中它,命中后 /// 全部直接出稿,没有任何一个 run 走到第 2 轮。 +/// - `UserRevision`:用户在审批卡上点「修改 / 退回」后的修订轮。它同样带 +/// `repairOfDelegationId`,但 `repair_depth` 防的是 runaway agent,而这一跳每一轮 +/// 都由人触发——人本身就是循环边界,所以 `static_delegate_lineage_counters` 早就 +/// 把 depth/round 原样继承了。缺的是这句话:走 `Repair` 分支时用户第一次点修改就 +/// 会被告知「这是唯一返工轮」,和澄清跳当初那个假天花板是同一个错误。原型对应的是 +/// `USER_REVISION_SOFT_LIMIT = 16`,且超过只提示、不拒绝。 /// - `None`:普通委派,不加这一段。 pub(in crate::agent) enum StaticDelegateHopNote<'a> { None, Repair { original_delegation_id: &'a str, }, + UserRevision { + original_delegation_id: &'a str, + }, PlanClarification { original_delegation_id: &'a str, rounds_used: u32, @@ -238,6 +247,11 @@ impl StaticDelegateHopNote<'_> { StaticDelegateHopNote::Repair { original_delegation_id, } => format!("\n\n这是对已认领委派 {original_delegation_id} 的唯一返工轮。"), + StaticDelegateHopNote::UserRevision { + original_delegation_id, + } => format!( + "\n\n这是对已认领委派 {original_delegation_id} 的用户修订轮,由用户在审批卡上提出,不是质量返工,不消耗返工深度,也不重置澄清轮次。按任务正文里的用户意见原文修订同一份 GDD 谱系后重新提交;用户看过新稿还可以再次提出修改,这不是最后一轮,不要因此压缩改动或提前收尾。" + ), // 预算用尽:planning_coordinator 出卡时会用 // `current_round >= 3` 直接拒掉第四张卡,所以这里不能再邀请提问, // 只能要求收稿——语义上等价于原型的 INJ_MUST_DRAFT_ROUNDS。 @@ -256,7 +270,7 @@ impl StaticDelegateHopNote<'_> { rounds_used, rounds_limit, } => format!( - "\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮,不消耗返工深度。已用澄清轮次 {rounds_used}/{rounds_limit}。仍有会实质改变结果的空白且预算未用尽时,可以继续以 AGC_NEEDS_USER_INPUT_V1 信封退出:questions 恰好一题,header 必须精确等于「第{next_round}轮·关键决定」。预算已用尽,或剩余空白能由默认建议覆盖且不影响首个可玩闭环时,立即提交 GDD。", + "\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮,不消耗返工深度。已用澄清轮次 {rounds_used}/{rounds_limit}。仍有会实质改变结果的空白且预算未用尽时,可以继续以 AGC_NEEDS_USER_INPUT_V1 信封退出:questions 恰好一题,header 写成「第{next_round}轮·当前要决定:<主题>」,轮号必须是 {next_round},主题写这一轮真正要定的那件事。预算已用尽,或剩余空白能由默认建议覆盖且不影响首个可玩闭环时,立即提交 GDD。", next_round = rounds_used.saturating_add(1), ), } @@ -651,6 +665,29 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( } else { None }; + // 用户修订跳同样带 repairOfDelegationId,但它是人触发的,不该拿到「唯一返工轮」 + // 那句话。判据用原 delivery 的 contractStatus,并同样只作用于立项策划链路: + // `mark_static_delegate_delivery_user_revision_requested_at` 只由策划审批调用, + // 这里再加一道 target 门,做游戏 / 做素材的返工跳逐字保持 Repair 分支。 + let user_revision_hop = match repair_of_delegation_id.as_deref() { + Some(original) + if plan_clarification_rounds.is_none() + && target_agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID => + { + match static_delegate_original_awaits_user_revision_at(root, original) { + Ok(value) => value, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + } + } + _ => false, + }; let hop_note = match ( repair_of_delegation_id.as_deref(), plan_clarification_rounds, @@ -662,6 +699,11 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( rounds_limit, } } + (Some(original_delegation_id), None) if user_revision_hop => { + StaticDelegateHopNote::UserRevision { + original_delegation_id, + } + } (Some(original_delegation_id), None) => StaticDelegateHopNote::Repair { original_delegation_id, }, @@ -1688,6 +1730,40 @@ mod tests { /// 「你只剩这一轮」——这正是生产上 4 次澄清续跑之后无一走到第 2 轮的原因。 /// 同时钉住轮号:`planning_coordinator` 出卡时按 `rounds_used + 1` 校验 header, /// 这里写进 task 的必须是同一个数,否则第 2 轮信封会当场被拒。 + /// 用户修订轮同样不能套返工文案。 + /// + /// 「唯一返工轮」防的是 runaway agent,而这一跳由用户在审批卡上亲手点出来——人本身 + /// 就是循环边界,`static_delegate_lineage_counters` 早就把 depth/round 原样继承了。 + /// 套用返工文案就是告诉策划子 Agent「用户只能改这一次」,和澄清跳当初那个假天花板 + /// 是同一个错误。原型对应的是软阈值 16 次、超过只提示不拒绝。 + #[test] + fn user_revision_hop_note_is_not_the_repair_round_note() { + let revision = render_static_delegate_task_contract( + "任务", + "project-supervisor", + "run-1", + "delegation-new", + &["交付 game/fast_gdd.md".to_string()], + &["game/fast_gdd.md".to_string()], + StaticDelegateHopNote::UserRevision { + original_delegation_id: "delegation-old", + }, + ) + .expect("render user revision hop note"); + assert!( + !revision.contains("唯一返工轮"), + "用户修订轮不得复用返工文案,否则子 Agent 以为用户只能改这一次:{revision}" + ); + assert!( + revision.contains("不消耗返工深度"), + "必须写明它不吃返工额度:{revision}" + ); + assert!( + revision.contains("不是最后一轮"), + "必须写明用户还能再改,否则子 Agent 会把多条意见攒到一轮改完:{revision}" + ); + } + #[test] fn plan_clarification_hop_note_is_not_the_repair_round_note() { let repair = render_static_delegate_task_contract( @@ -1730,7 +1806,7 @@ mod tests { "澄清续跑必须写明已用轮次与上限:{clarification}" ); assert!( - clarification.contains("第2轮·关键决定"), + clarification.contains("第2轮·当前要决定:"), "task 里的轮号必须等于 planning_coordinator 校验 header 时用的 rounds_used + 1:{clarification}" ); @@ -1753,7 +1829,7 @@ mod tests { "预算用尽时必须要求收稿,出卡侧会直接拒掉第四张卡:{exhausted}" ); assert!( - !exhausted.contains("第4轮·关键决定"), + !exhausted.contains("第4轮·当前要决定:"), "预算用尽时不得再给出下一轮 header,那是一张永远递不上去的卡:{exhausted}" ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 947760180..8ef1b13d1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1062,8 +1062,8 @@ fn plan_string_array_schema(min_items: usize, max_items: usize, item_max_length: /// Strict provider-facing shape for `plan-submit-gdd-input.v1`. /// /// Runtime-injected identity, platform facts, version and fingerprint fields -/// deliberately do not appear here. The durable handler performs the -/// semantic/session equality checks after parsing this wire shape. +/// deliberately do not appear here. The durable handler performs the +/// structural, identity and CAS checks after parsing this wire shape. fn plan_submit_gdd_input_schema() -> Value { let decision_state = json!({ "type": "string", @@ -1071,7 +1071,7 @@ fn plan_submit_gdd_input_schema() -> Value { }); let answer_source = json!({ "type": "string", - "enum": ["user_freeform", "user_option", "default"] + "enum": ["user_freeform", "user_option", "user_revision", "default"] }); let pillar = json!({ "type": "object", @@ -2130,6 +2130,7 @@ mod tests { for stage in [ PlanRootSupervisorStage::GoalContract, PlanRootSupervisorStage::Delegate, + PlanRootSupervisorStage::AwaitingAcceptanceEvidence, PlanRootSupervisorStage::Delegated, ] { let mut staged = functions.clone(); @@ -2160,6 +2161,7 @@ mod tests { for stage in [ PlanRootSupervisorStage::GoalContract, PlanRootSupervisorStage::Delegate, + PlanRootSupervisorStage::AwaitingAcceptanceEvidence, PlanRootSupervisorStage::Delegated, ] { let mut staged = functions.clone(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index 873662bca..db05d7596 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -759,7 +759,6 @@ fn project_command_argument_contains_sensitive_path(value: &str) -> bool { | ".git" | ".agents" | ".codex" - | ".hermes" | ".hg" | ".svn" | ".ssh" @@ -2280,7 +2279,7 @@ mod tests { async fn project_command_workspace_sandbox_blocks_host_controls_and_network() { let dir = command_project("workspace-sandbox"); let root = dir.path(); - for name in [".git", ".agents", ".codex", ".hermes"] { + for name in [".git", ".agents", ".codex"] { fs::create_dir_all(root.join(name)).expect("create protected directory"); fs::write(root.join(name).join("marker"), name).expect("write protected marker"); } @@ -2294,7 +2293,7 @@ mod tests { printf WORKSPACE_OK > workspace-write.txt test ! -r {outside:?} ! printf NO > {outside:?} -for control in .git .agents .codex .hermes; do +for control in .git .agents .codex; do test -r "$control/marker" ! touch "$control/blocked-write" done @@ -2326,7 +2325,7 @@ raise SystemExit(code)' fs::read_to_string(&outside).expect("outside sentinel"), "HOST_SECRET" ); - for name in [".git", ".agents", ".codex", ".hermes"] { + for name in [".git", ".agents", ".codex"] { assert!(!root.join(name).join("blocked-write").exists()); } fs::remove_file(outside).ok(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs index 11725bddf..ed2a9cdf1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs @@ -181,7 +181,7 @@ mod linux { use std::time::{Duration, Instant}; const TRUSTED_BWRAP_PATHS: [&str; 2] = ["/usr/bin/bwrap", "/bin/bwrap"]; - const PROTECTED_READ_ONLY_NAMES: [&str; 4] = [".git", ".agents", ".codex", ".hermes"]; + const PROTECTED_READ_ONLY_NAMES: [&str; 3] = [".git", ".agents", ".codex"]; const TOOLCHAIN_ENVIRONMENT_ROOTS: [&str; 4] = ["RUSTUP_HOME", "JAVA_HOME", "GOROOT", "DOTNET_ROOT"]; const FIXED_SYSTEM_READ_ONLY_PATHS: [&str; 8] = [ @@ -962,7 +962,6 @@ mod linux { PathBuf::from("/workspace/project/.git"), PathBuf::from("/workspace/project/.agents"), PathBuf::from("/workspace/project/.codex"), - PathBuf::from("/workspace/project/.hermes"), ], external_read_only: vec![ReadOnlyMount { source: PathBuf::from("/opt/toolchain/bin/tool"), @@ -995,7 +994,7 @@ mod linux { &launch.arguments, &["--bind", "/workspace/project", "/workspace/project"] )); - for path in [".git", ".agents", ".codex", ".hermes"] { + for path in [".git", ".agents", ".codex"] { let path = format!("/workspace/project/{path}"); assert!(has_sequence( &launch.arguments, @@ -1162,7 +1161,7 @@ mod linux { let outside = tree.0.join("outside-secret.txt"); std::fs::create_dir_all(&root).expect("create workspace"); std::fs::write(&outside, "OUTSIDE_SECRET").expect("write outside secret"); - for name in [".agent", ".git", ".agents", ".codex", ".hermes"] { + for name in [".agent", ".git", ".agents", ".codex"] { std::fs::create_dir_all(root.join(name)).expect("create control directory"); std::fs::write(root.join(name).join("marker"), name).expect("write control marker"); } @@ -1175,7 +1174,7 @@ import subprocess Path("workspace-write.txt").write_text("WORKSPACE_OK") assert Path("workspace-write.txt").read_text() == "WORKSPACE_OK" -for control in [".git", ".agents", ".codex", ".hermes"]: +for control in [".git", ".agents", ".codex"]: assert Path(control, "marker").read_text() == control try: Path(control, "blocked-write").write_text("NO") @@ -1259,7 +1258,7 @@ print("SANDBOX_OK") std::fs::read_to_string(&outside).expect("outside secret unchanged"), "OUTSIDE_SECRET" ); - for name in [".git", ".agents", ".codex", ".hermes"] { + for name in [".git", ".agents", ".codex"] { assert!(!root.join(name).join("blocked-write").exists()); } } @@ -1272,7 +1271,7 @@ print("SANDBOX_OK") let tree = unique_temp_tree(); let root = tree.0.join("workspace-staged-gate"); std::fs::create_dir_all(&root).expect("create workspace"); - for name in [".agent", ".git", ".agents", ".codex", ".hermes"] { + for name in [".agent", ".git", ".agents", ".codex"] { std::fs::create_dir_all(root.join(name)).expect("create control directory"); } let marker = root.join("committed-target-ran"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 15809d736..73221b283 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -2802,7 +2802,6 @@ mod agent_asset_import_tests { for (index, directory) in [ ".git", ".codex", - ".hermes", "node_modules", "target", "dist", @@ -3350,7 +3349,7 @@ fn reject_agent_local_image_source_path(normalized_path: &str) -> Result<(), Str if should_skip_project_snapshot_path(normalized_path) || normalized_path .split('/') - .any(|part| part.eq_ignore_ascii_case(".codex") || part.eq_ignore_ascii_case(".hermes")) + .any(|part| part.eq_ignore_ascii_case(".codex")) { return Err("本地图片导入不得访问隐藏、构建或工具控制目录".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index 16e20945b..f49737d6b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -28,16 +28,24 @@ pub(crate) fn static_delegate_result_detail_max_chars( value: &str, default_max_chars: usize, ) -> usize { - if value - .trim_start() - .starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX) - { + if response_is_static_delegate_user_input_envelope(value) { STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS } else { default_max_chars } } +/// 这条回复是不是澄清信封,而不是一次交付收束。 +/// +/// 收束门禁按「任务是否做完」判据拦最终回复,而澄清信封恰恰相反:它是本 run +/// 就此挂起、把决定权交回用户,剩下的工作由用户答完之后的 continuation run 接着 +/// 做。用完成度判据去拦它,对任何含「答完之后再做 X」步骤的计划都不可满足。 +pub(crate) fn response_is_static_delegate_user_input_envelope(response: &str) -> bool { + response + .trim_start() + .starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX) +} + /// 构造一份贴着问询 schema 上限的合法澄清信封,供跨模块的通道用例复用。 /// 通道必须容得下 schema 允许的最大合法问询,而不只是「碰巧短」的那一条。 #[cfg(test)] @@ -278,12 +286,33 @@ impl StaticDelegateCompletionBarrier { && self.unknown_contract_status_count == 0 } + /// 自动恢复/唤醒路径该不该收手。 + /// + /// `user_revision_pending_count` 计在这里是承重的:用户修订是一条显式的 Supervisor + /// 决策边界,在它派出续作之前父 run 绝不能被自动恢复(`runtime_tools/delivery.rs` + /// 的 `debug_assert` 把这份跨文件依赖钉在使用现场)。 pub(crate) fn has_waiting(self) -> bool { self.waiting_count > 0 || self.user_revision_pending_count > 0 || self.unknown_contract_status_count > 0 } + /// 当前正在跑的这一轮,有没有**外部事件**值得 park 着等。 + /// + /// 和 `has_waiting()` 问的是相反的问题,所以刻意不计 `user_revision_pending_count`: + /// - `waitingDelegations > 0`:子 Agent 正在跑,park 等它 —— 会有回执到来。 + /// - `unknownContractStatus > 0`:fail-closed,宁可停下也不按未知状态行动。 + /// - `userRevisionPending > 0`:**没有任何东西在跑**。那条回执只能来自本 run 自己 + /// 创建的修订委派,park 等它就是等自己,必然死锁。 + /// + /// 生产实测:用户点「修改」后 Supervisor park 在「等待专业 Agent 委派回执 / 回执全部 + /// ready 后自动唤醒当前父 run」,8 分钟零事件——它在等一条只有它自己能造出来的回执。 + /// main_loop 里本来就有一条专为 user_revision 写的分支(`phase=planning`、 + /// `next_step=调用 agent.delegate…`),但被上游这道 park 门截胡了。 + pub(crate) fn has_external_wait(self) -> bool { + self.waiting_count > 0 || self.unknown_contract_status_count > 0 + } + pub(crate) fn detail(self) -> String { format!( "waitingDelegations={} · readyUnclaimedReceipts={} · unobservedReceiptClaims={} · repairRequired={} · userInputRequired={} · userRevisionPending={} · unknownContractStatus={} · 必须认领专业 Agent 回执,处理 needs-user-input/needs-repair/user-revision-requested,或升级客户端后再继续", @@ -497,6 +526,42 @@ pub(crate) fn mark_static_delegate_delivery_ready_with_result_at( Ok(delivery) } +/// claim 里的 `structuredResult` 是「父 Agent 在那个 action 上观察到了什么」的冻结 +/// 快照;delivery 是当前真相。两者绝大多数时候必须逐字相等——不等就是漂移或篡改。 +/// +/// 唯一的例外是审批:用户在审批卡上点「修改 / 退回」后, +/// `mark_static_delegate_delivery_user_revision_requested_at` 会把 delivery 从 +/// `EvidenceReady` 原地改写成 `UserRevisionRequested`,而 claim 快照仍停在 +/// `EvidenceReady`。那不是漂移,是一次只由审批产生、且只能朝这个方向走的合法转移; +/// 快照记的那句「当时观察到 evidence-ready」现在依然为真,不该被改写。 +/// +/// 按全等判会把它当成冲突:`agent.run_status` 每次重放这条 claim 都 failed, +/// Supervisor 永远拿不到回执、也就永远建不出修订委派。生产实测卡死在第 43 轮空转, +/// 报「静态委派 claim 与 delivery 身份或结果冲突」。原型没有 claim 这层快照,单一 +/// 真相就地改,结构上不存在这个冲突——这里翻译的是同一个语义:比较的是「delivery 是 +/// 不是 receipt 的合法后继」,不是「两者永远全等」。 +/// +/// 放行面刻意压到最小:除 `contractStatus` 外每个字段都必须逐字不变,且方向唯一。 +fn static_delegate_structured_result_follows_claim_snapshot( + snapshot: Option<&StaticDelegateStructuredResult>, + current: Option<&StaticDelegateStructuredResult>, +) -> bool { + if snapshot == current { + return true; + } + let (Some(snapshot), Some(current)) = (snapshot, current) else { + return false; + }; + if snapshot.contract_status != StaticDelegateContractStatus::EvidenceReady + || current.contract_status != StaticDelegateContractStatus::UserRevisionRequested + { + return false; + } + let mut rebased = current.clone(); + rebased.contract_status = StaticDelegateContractStatus::EvidenceReady; + rebased == *snapshot +} + /// Mark an already claimed, evidence-ready planning delivery as waiting for a /// user-requested revision. Approval is the only producer of this durable /// status; keeping the transition here makes its evidence precondition and @@ -1097,7 +1162,10 @@ fn commit_static_delegate_claim_with_locks_with_budget_at( || delivery.acceptance_criteria != receipt.acceptance_criteria || delivery.expected_artifacts != receipt.expected_artifacts || delivery.repair_of_delegation_id != receipt.repair_of_delegation_id - || delivery.structured_result != receipt.structured_result + || !static_delegate_structured_result_follows_claim_snapshot( + receipt.structured_result.as_ref(), + delivery.structured_result.as_ref(), + ) { return Err(format!( "静态委派 claim 与 delivery 身份或结果冲突:{}", @@ -1216,6 +1284,21 @@ fn static_delegate_original_is_awaiting_clarification( /// /// 该状态只由后续审批工作包写入;本包只让 lineage 重放认识它,不能自行生成或 /// 把其它状态静默映射成它。 +/// 该原 delivery 是否正等着用户提出的修订(而不是质量返工)。 +/// +/// 用户修订和质量返工都带 `repairOfDelegationId`,但额度完全不同:`repair_depth` +/// 防的是 runaway agent,而用户修订每一轮都由人触发,人本身就是循环边界。委派 task +/// 末尾那句「你在这条链路上的位置」必须按这个判据分开渲染,否则用户第一次点修改就会 +/// 被告知「这是唯一返工轮」。 +pub(crate) fn static_delegate_original_awaits_user_revision_at( + root: &Path, + delegation_id: &str, +) -> Result { + Ok(read_static_delegate_delivery_at(root, delegation_id)? + .as_ref() + .is_some_and(static_delegate_original_is_user_revision_requested)) +} + fn static_delegate_original_is_user_revision_requested( delivery: &StaticDelegateDeliveryRecord, ) -> bool { @@ -2560,8 +2643,8 @@ mod tests { serde_json::json!({ "questions": [{ "id": "plan_round_1", - "header": "第1轮·关键决定", - "question": "当前要决定:影子能力在首个可玩闭环中的核心作用。它会同时决定关卡布局、操作手感与原型优先级,也决定第一批谜题按什么规则组合;现在确认可以避免把三种玩法都做浅,也避免原型做到一半再推翻核心规则。", + "header": "第1轮·当前要决定:影子能力在首个可玩闭环中的核心作用", + "question": "它会同时决定关卡布局、操作手感与原型优先级,也决定第一批谜题按什么规则组合;现在确认可以避免把三种玩法都做浅,也避免原型做到一半再推翻核心规则。", "options": [ { "label": "A · 影子化为可独立移动的暗影分身", @@ -2602,12 +2685,12 @@ mod tests { // abtest-tide2A-2:尾巴是「 马会」。 concat!( "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"replay_motivation","header":"第1轮·关键决定","question":"当前要决定:固定五岛海图的重复游玩动力采用哪种方案?现在确认它,才能锁定首个可玩闭环之外的得分与重开目标。","options":[{"label":"A · 推荐:固定布局冲榜","description":"每局地图与信件配置固定,玩家通过更优路线、潮汐 timing 和装卸顺序刷新送达数与总分;优点是实现最小、可读性强,代价是内容变化较少。"},{"label":"B · 轮换信件组合","description":"地图固定但每局从预设信件组合中轮换收件岛与期限;优点是重玩变化更明显,代价是需要额外平衡组合并降低可预测性。"},{"label":"需要原型验证","description":"用30~90分钟做可点击五岛地图与两种信件配置原型,让3名偏好轻策略的玩家各玩3局,观察是否主动重开及路线是否有差异;通过标准是多数玩家愿意重开且能说出改进路线。"}]}]} 马会"#, + r#"{"questions":[{"id":"replay_motivation","header":"第1轮·当前要决定:固定五岛海图的重复游玩动力","question":"现在确认它,才能锁定首个可玩闭环之外的得分与重开目标。","options":[{"label":"A · 推荐:固定布局冲榜","description":"每局地图与信件配置固定,玩家通过更优路线、潮汐 timing 和装卸顺序刷新送达数与总分;优点是实现最小、可读性强,代价是内容变化较少。"},{"label":"B · 轮换信件组合","description":"地图固定但每局从预设信件组合中轮换收件岛与期限;优点是重玩变化更明显,代价是需要额外平衡组合并降低可预测性。"},{"label":"需要原型验证","description":"用30~90分钟做可点击五岛地图与两种信件配置原型,让3名偏好轻策略的玩家各玩3局,观察是否主动重开及路线是否有差异;通过标准是多数玩家愿意重开且能说出改进路线。"}]}]} 马会"#, ), // verify-farm-4:尾巴是古吉拉特语字母、西里尔字母和中文垃圾词的混合物。 concat!( "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"replay_progression","header":"第2轮·关键决定","question":"当前要决定:自由经营农场的长期目标采用哪种组合?这会决定玩家为何持续规划、赚钱与重玩,并控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施,同时完成可选成就;优点是目标清晰又保留自由安排,代价是需要同时做基础升级与成就追踪。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施,成就仅作展示;优点是系统更聚焦、反馈直接,代价是挑战层次和重玩目标较少。"},{"label":"需要原型验证","description":"制作 30–90 分钟微型原型,让 2–3 名目标玩家试玩两种目标结构,观察他们是否主动设定计划、理解进展并愿意继续经营;多数玩家能完成一次扩建且愿意追求第二个目标即通过。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, + r#"{"questions":[{"id":"replay_progression","header":"第2轮·当前要决定:自由经营农场的长期目标","question":"这会决定玩家为何持续规划、赚钱与重玩,并控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施,同时完成可选成就;优点是目标清晰又保留自由安排,代价是需要同时做基础升级与成就追踪。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施,成就仅作展示;优点是系统更聚焦、反馈直接,代价是挑战层次和重玩目标较少。"},{"label":"需要原型验证","description":"制作 30–90 分钟微型原型,让 2–3 名目标玩家试玩两种目标结构,观察他们是否主动设定计划、理解进展并愿意继续经营;多数玩家能完成一次扩建且愿意追求第二个目标即通过。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, ), ]; for response in cases { @@ -2628,7 +2711,7 @@ mod tests { // verify-farm-2 现场原文,结尾是 `}]}` 而非 `}]}]}`。 let response = concat!( "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"core_loop_goal","header":"第1轮·关键决定","question":"当前要决定:这款农场经营游戏的一局,玩家主要通过什么目标获得满足?现在先定核心闭环,才能控制 MVP 范围。","options":[{"label":"A · 推荐:短周期订单经营","description":"围绕播种、收获、加工并完成限时订单推进;目标清晰、反馈快,代价是自由建造与长期规划较少。"},{"label":"B · 自主农场成长","description":"围绕规划田地、逐步扩建并达成阶段里程碑;沉浸和成长感更强,代价是前期目标反馈较慢、系统边界更难控。"},{"label":"需要原型验证","description":"制作 30~90 分钟微型原型,包含种植、收获和一种目标;让 2~3 名目标玩家试玩,观察是否理解目标、是否愿意继续一轮;通过标准是多数玩家无需讲解即可完成闭环并主动开始第二轮。"}]}"#, + r#"{"questions":[{"id":"core_loop_goal","header":"第1轮·当前要决定:一局里玩家靠什么目标获得满足","question":"现在先定核心闭环,才能控制 MVP范围。","options":[{"label":"A · 推荐:短周期订单经营","description":"围绕播种、收获、加工并完成限时订单推进;目标清晰、反馈快,代价是自由建造与长期规划较少。"},{"label":"B · 自主农场成长","description":"围绕规划田地、逐步扩建并达成阶段里程碑;沉浸和成长感更强,代价是前期目标反馈较慢、系统边界更难控。"},{"label":"需要原型验证","description":"制作 30~90 分钟微型原型,包含种植、收获和一种目标;让 2~3 名目标玩家试玩,观察是否理解目标、是否愿意继续一轮;通过标准是多数玩家无需讲解即可完成闭环并主动开始第二轮。"}]}"#, ); let error = parse_static_delegate_user_input_request(Some(response)) .expect_err("an envelope that stops short of closing must not parse"); @@ -2832,8 +2915,8 @@ mod tests { // 实测形态:option 对象里多写了一个 `id` 字段。 let response = concat!( "AGC_NEEDS_USER_INPUT_V1\n", - "{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",", - "\"question\":\"当前要决定:核心闭环形状。\",\"options\":[", + "{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环形状\",", + "\"question\":\"它决定首个可玩闭环长什么样。\",\"options\":[", "{\"id\":\"a\",\"label\":\"A · 甲方案\",\"description\":\"甲方案的后果\"},", "{\"id\":\"b\",\"label\":\"B · 乙方案\",\"description\":\"乙方案的后果\"},", "{\"id\":\"c\",\"label\":\"需要原型验证\",\"description\":\"做个微型原型看看\"}]}]}" diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 25b23d44d..ab2dd513a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -312,6 +312,12 @@ struct AgentRuntimeState { /// Provider spend. #[serde(default)] plan_update_idle_rounds: u32, + /// Consecutive final replies this run had refused by a completion blocker. + /// Runtime-owned durable state for the same reason as the two counters + /// above: a blocker the model cannot satisfy is a livelock, and a runner + /// restart must not launder it back into unbounded Provider spend. + #[serde(default)] + stale_finalization_rounds: u32, #[serde(default)] max_loop_iterations: u32, #[serde(default)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs index 3e40c51b4..12eb9f5d0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs @@ -1152,12 +1152,6 @@ fn append_local_conversation_message_for_session_internal_at( ) } }; - if archived { - return Err(format!( - "Agent Session 已归档,只能读取:{}", - normalized_session_id.as_deref().unwrap_or_default() - )); - } let role = role.trim(); if !matches!(role, "user" | "assistant" | "tool") { return Err("对话角色必须是 user、assistant 或 tool".to_string()); @@ -1226,6 +1220,12 @@ fn append_local_conversation_message_for_session_internal_at( } false } else { + if archived { + return Err(format!( + "Agent Session 已归档,只能读取:{}", + normalized_session_id.as_deref().unwrap_or_default() + )); + } append_jsonl_line_unlocked(&path, &line, "对话记录")?; true }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 5b7ebc4bf..4ec311a4a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -515,8 +515,8 @@ fn planning_clarification_question_body_with_labels( let body = serde_json::json!({ "questions": [{ "id": question_id, - "header": format!("第{round}轮·关键决定"), - "question": format!("当前要决定:第{round}轮核心取舍。现在确认后才能继续收敛 Fast GDD。"), + "header": format!("第{round}轮·当前要决定:第{round}轮核心取舍"), + "question": format!("第{round}轮核心取舍现在确认后才能继续收敛 Fast GDD。"), "options": [ { "label": label_a, @@ -4421,6 +4421,42 @@ fn planning_clarification_user_revision_after_answer_preserves_round_for_revise_ &submitted_delivery.delegation_id, ) .expect("mark submitted delivery user-revision-requested"); + + // 审批改写 delivery 之后、派发修订委派之前,Supervisor 必然先调一次 + // `agent.run_status`,它会把这条已 observed 的 claim 整个重放一遍。claim 里的 + // structuredResult 仍是审批前那份 EvidenceReady 快照,而 delivery 已经是 + // UserRevisionRequested——这一步按全等判就会报「claim 与 delivery 身份或结果 + // 冲突」,Supervisor 从此拿不到回执,也就永远建不出下面那条修订委派。 + // + // 生产实测正是卡在这里:run_status 连续 failed、空转到第 43 轮。此前这个用例 + // 从 mark 直接跳到 dispatch,跳过的恰好是唯一会失败的那一步。 + let barrier = static_delegate_completion_barrier_at( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + ) + .expect("审批改写 delivery 后,claim 重放必须仍然成立"); + + // 屏障必须同时说出两件事:任务还不能收束,但**没有外部事件可等**。 + // + // 只判「不能收束」不够——修复前它正是这样:main_loop 把用户修订待办当成 + // 「有在等的委派」,park 成「等待专业 Agent 委派回执 / 回执全部 ready 后自动 + // 唤醒当前父 run」,而那条回执只能来自下面这条还没派出去的修订委派。生产实测 + // 8 分钟零事件。has_external_wait() 为假才能让本轮落进 user_revision 分支去 + // 调 agent.delegate。 + assert!( + !barrier.is_clear(), + "用户修订待办没派出续作前,父 run 不能被判为可收束" + ); + assert!( + !barrier.has_external_wait(), + "用户修订待办没有任何外部事件可等,park 住就是等自己派出的委派" + ); + assert!( + barrier.has_waiting(), + "自动恢复路径仍须收手:续作派出前不得跨过这条 Supervisor 决策边界" + ); + let revision_action_id = format!("planning-user-{action}-continuation"); let revision = dispatch_static_delegate_plain_repair( &fixture.root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 0f9d84221..2a464b0c0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -1557,17 +1557,35 @@ pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply( let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); std::thread::spawn(move || { let (mut planning_stream, _) = listener.accept().expect("mock tool plan accept"); - drop(read_mock_http_request(&mut planning_stream)); - let planning_body = serde_json::json!({ - "id": "resp_invalid_final_reply_planning", - "model": "mock-game-model", - "output_text": planning_response, - "status": "completed", - "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } - }) - .to_string(); + let planning_request = read_mock_http_request(&mut planning_stream); + let planning_streaming = planning_request.contains("\"stream\":true"); + let (planning_body, planning_content_type) = if planning_streaming { + ( + format!( + "data: {}\n\ndata: {}\n\n", + serde_json::json!({ + "type": "response.output_text.delta", + "delta": planning_response + }), + serde_json::json!({ "type": "response.completed" }) + ), + "text/event-stream; charset=utf-8", + ) + } else { + ( + serde_json::json!({ + "id": "resp_invalid_final_reply_planning", + "model": "mock-game-model", + "output_text": planning_response, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }) + .to_string(), + "application/json", + ) + }; let planning_response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 200 OK\r\nContent-Type: {planning_content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", planning_body.len(), planning_body ); @@ -1607,6 +1625,74 @@ pub(crate) fn final_tool_plan_response(response: impl Into) -> String { .to_string() } +pub(crate) fn native_anthropic_tool_plan_response( + call_id: &str, + function_name: &str, + arguments: &str, +) -> String { + let events = vec![ + serde_json::json!({ + "type": "message_start", + "message": { "usage": { "input_tokens": 11, "output_tokens": 22 } } + }), + serde_json::json!({ + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": call_id, + "name": function_name, + "input": {} + } + }), + serde_json::json!({ + "type": "content_block_delta", + "index": 0, + "delta": { "type": "input_json_delta", "partial_json": arguments } + }), + serde_json::json!({ "type": "content_block_stop", "index": 0 }), + serde_json::json!({ "type": "message_delta", "delta": { "stop_reason": "tool_use" } }), + serde_json::json!({ "type": "message_stop" }), + ]; + events + .iter() + .map(|event| format!("data: {event}\n\n")) + .collect() +} + +pub(crate) fn native_anthropic_text_stream_response(text: &str) -> String { + let events = vec![ + serde_json::json!({ + "type": "message_start", + "message": { "usage": { "input_tokens": 11, "output_tokens": 0 } } + }), + serde_json::json!({ + "type": "content_block_start", + "index": 0, + "content_block": { "type": "text", "text": "" } + }), + serde_json::json!({ + "type": "content_block_delta", + "index": 0, + "delta": { "type": "text_delta", "text": text } + }), + serde_json::json!({ + "type": "content_block_stop", + "index": 0 + }), + serde_json::json!({ + "type": "message_delta", + "delta": { "stop_reason": "end_turn" }, + "usage": { "output_tokens": 22 } + }), + serde_json::json!({ "type": "message_stop" }), + ]; + events + .iter() + .map(|event| format!("data: {event}\n\n")) + .collect() +} + fn user_input_tool_plan_response(question: &str) -> String { serde_json::json!({ "thinkingSummary": "实现路径取决于用户选择,需要先暂停并澄清", @@ -1694,30 +1780,65 @@ fn spawn_mock_llm_server_responses_with_capture( if let Some(sender) = request_sender.as_ref() { let _ = sender.send(request_text.clone()); } - let body = if request_text.contains("POST /responses HTTP/1.1") { - serde_json::json!({ - "id": "resp_game_creator_mock", - "model": "mock-game-model", - "output_text": response_content, - "status": "completed", - "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } - }) + let is_responses = request_text.contains("POST /responses HTTP/1.1"); + let is_stream = request_text.contains("\"stream\":true"); + let (body, content_type) = if is_stream && is_responses { + ( + format!( + "data: {}\n\ndata: {}\n\n", + serde_json::json!({ + "type": "response.output_text.delta", + "delta": response_content + }), + serde_json::json!({ "type": "response.completed" }) + ), + "text/event-stream; charset=utf-8", + ) + } else if is_stream { + ( + format!( + "data: {}\n\ndata: [DONE]\n\n", + serde_json::json!({ + "id": "chatcmpl_game_creator_mock", + "object": "chat.completion.chunk", + "choices": [{ + "index": 0, + "delta": { "role": "assistant", "content": response_content }, + "finish_reason": "stop" + }] + }) + ), + "text/event-stream; charset=utf-8", + ) + } else if is_responses { + ( + serde_json::json!({ + "id": "resp_game_creator_mock", + "model": "mock-game-model", + "output_text": response_content, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }) + .to_string(), + "application/json", + ) } else { - serde_json::json!({ - "id": "chatcmpl_game_creator_mock", - "model": "mock-game-model", - "choices": [ - { + ( + serde_json::json!({ + "id": "chatcmpl_game_creator_mock", + "model": "mock-game-model", + "choices": [{ "message": { "content": response_content }, "finish_reason": "stop" - } - ], - "usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 } - }) - } - .to_string(); + }], + "usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 } + }) + .to_string(), + "application/json", + ) + }; let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body ); @@ -2091,18 +2212,34 @@ fn spawn_mock_llm_tool_plan_then_transient_final_reply( .try_into() .unwrap_or(u64::MAX); let planning_request = read_mock_http_request(&mut planning_stream); - let _ = request_capture_sender.send((planning_accepted_at_ms, planning_request)); + let _ = request_capture_sender.send((planning_accepted_at_ms, planning_request.clone())); let _ = request_notice_sender.send(()); - let planning_body = serde_json::json!({ - "id": "resp_transient_final_reply_planning", - "model": "mock-game-model", - "output_text": planning_response, - "status": "completed", - "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } - }) - .to_string(); + let planning_body = if planning_request.contains("\"stream\":true") { + format!( + "data: {}\n\ndata: {}\n\n", + serde_json::json!({ + "type": "response.output_text.delta", + "delta": planning_response + }), + serde_json::json!({ "type": "response.completed" }) + ) + } else { + serde_json::json!({ + "id": "resp_transient_final_reply_planning", + "model": "mock-game-model", + "output_text": planning_response, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }) + .to_string() + }; + let planning_content_type = if planning_request.contains("\"stream\":true") { + "text/event-stream; charset=utf-8" + } else { + "application/json" + }; let planning_http_response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 200 OK\r\nContent-Type: {planning_content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", planning_body.len(), planning_body ); @@ -2182,7 +2319,7 @@ fn spawn_mock_llm_tool_plan_then_transient_final_compaction( let (mut planning_stream, _) = listener.accept().expect("mock tool plan accept"); let planning_accepted_at_ms = accepted_at_ms(); let planning_request = read_mock_http_request(&mut planning_stream); - let _ = request_capture_sender.send((planning_accepted_at_ms, planning_request)); + let _ = request_capture_sender.send((planning_accepted_at_ms, planning_request.clone())); replace_test_local_config( &config_path, format!( @@ -2206,16 +2343,32 @@ fn spawn_mock_llm_tool_plan_then_transient_final_compaction( ), ); let _ = request_notice_sender.send(()); - let planning_body = serde_json::json!({ - "id": "resp_final_compaction_planning", - "model": "mock-game-model", - "output_text": planning_response, - "status": "completed", - "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } - }) - .to_string(); + let planning_body = if planning_request.contains("\"stream\":true") { + format!( + "data: {}\n\ndata: {}\n\n", + serde_json::json!({ + "type": "response.output_text.delta", + "delta": planning_response + }), + serde_json::json!({ "type": "response.completed" }) + ) + } else { + serde_json::json!({ + "id": "resp_final_compaction_planning", + "model": "mock-game-model", + "output_text": planning_response, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }) + .to_string() + }; + let planning_content_type = if planning_request.contains("\"stream\":true") { + "text/event-stream; charset=utf-8" + } else { + "application/json" + }; let planning_http_response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 200 OK\r\nContent-Type: {planning_content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", planning_body.len(), planning_body ); @@ -2240,19 +2393,35 @@ fn spawn_mock_llm_tool_plan_then_transient_final_compaction( let recovered_compaction_request = read_mock_http_request(&mut recovered_compaction_stream); let _ = request_capture_sender.send(( recovered_compaction_accepted_at_ms, - recovered_compaction_request, + recovered_compaction_request.clone(), )); let _ = request_notice_sender.send(()); - let compaction_body = serde_json::json!({ - "id": "resp_final_compaction_recovered", - "model": "mock-game-model", - "output_text": compaction_response, - "status": "completed", - "usage": { "input_tokens": 44, "output_tokens": 12, "total_tokens": 56 } - }) - .to_string(); + let compaction_body = if recovered_compaction_request.contains("\"stream\":true") { + format!( + "data: {}\n\ndata: {}\n\n", + serde_json::json!({ + "type": "response.output_text.delta", + "delta": compaction_response + }), + serde_json::json!({ "type": "response.completed" }) + ) + } else { + serde_json::json!({ + "id": "resp_final_compaction_recovered", + "model": "mock-game-model", + "output_text": compaction_response, + "status": "completed", + "usage": { "input_tokens": 44, "output_tokens": 12, "total_tokens": 56 } + }) + .to_string() + }; + let compaction_content_type = if recovered_compaction_request.contains("\"stream\":true") { + "text/event-stream; charset=utf-8" + } else { + "application/json" + }; let compaction_http_response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 200 OK\r\nContent-Type: {compaction_content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", compaction_body.len(), compaction_body ); @@ -2290,9 +2459,36 @@ fn spawn_mock_llm_tool_plan_then_transient_final_compaction( fn spawn_mock_llm_raw_responses_with_capture( response_bodies: Vec, request_sender: Option>, +) -> String { + spawn_mock_llm_raw_responses_with_content_type_with_capture( + response_bodies + .into_iter() + .map(|body| body.to_string()) + .collect(), + request_sender, + "application/json", + ) +} + +fn spawn_mock_llm_stream_responses_with_capture( + response_bodies: Vec, + request_sender: Option>, +) -> String { + spawn_mock_llm_raw_responses_with_content_type_with_capture( + response_bodies, + request_sender, + "text/event-stream; charset=utf-8", + ) +} + +fn spawn_mock_llm_raw_responses_with_content_type_with_capture( + response_bodies: Vec, + request_sender: Option>, + content_type: &str, ) -> String { let listener = bind_test_tcp_listener("mock raw llm bind"); let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); + let content_type = content_type.to_string(); std::thread::spawn(move || { for response_body in response_bodies { let (mut stream, _) = listener.accept().expect("mock raw llm accept"); @@ -2300,11 +2496,11 @@ fn spawn_mock_llm_raw_responses_with_capture( if let Some(sender) = request_sender.as_ref() { let _ = sender.send(request_text); } - let body = response_body.to_string(); let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body + "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + content_type, + response_body.len(), + response_body ); stream .write_all(response.as_bytes()) @@ -2468,6 +2664,11 @@ enum ResponseStreamMockFinalResponse { Disconnect, } +enum ResponseStreamMockPlanningResponse { + NonStream(String), + Stream(String), +} + struct ResponseStreamMockServer { base_url: String, first_delta_written: mpsc::Receiver<()>, @@ -2487,7 +2688,7 @@ impl ResponseStreamMockServer { fn spawn_response_stream_mock_llm_server( api_kind: &str, - planning_response: String, + planning_response: ResponseStreamMockPlanningResponse, final_response: Option, ) -> ResponseStreamMockServer { let listener = bind_test_tcp_listener("response stream mock bind"); @@ -2501,39 +2702,72 @@ fn spawn_response_stream_mock_llm_server( let (stop, stop_receiver) = mpsc::channel(); let handle = std::thread::spawn(move || { let mut requests = Vec::new(); - let (mut planning_stream, _) = listener - .accept() - .expect("response stream planning request accept"); - let planning_request = read_mock_http_request(&mut planning_stream); - requests.push(planning_request); - let planning_body = match api_kind.as_str() { - "openai_responses" => serde_json::json!({ - "id": "resp_response_stream_planning", - "model": "response-stream-model", - "output_text": planning_response, - "status": "completed", - "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } - }), - "openai_chat" => serde_json::json!({ - "id": "chatcmpl_response_stream_planning", - "model": "response-stream-model", - "choices": [{ - "message": { "content": planning_response }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 } - }), - other => panic!("unsupported response stream mock api kind: {other}"), + let planning_http_response = match planning_response { + ResponseStreamMockPlanningResponse::NonStream(planning_response) => { + let planning_body = match api_kind.as_str() { + "openai_responses" => serde_json::json!({ + "id": "resp_response_stream_planning", + "model": "response-stream-model", + "output_text": planning_response, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }), + "openai_chat" => serde_json::json!({ + "id": "chatcmpl_response_stream_planning", + "model": "response-stream-model", + "choices": [{ + "message": { "content": planning_response }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 } + }), + other => panic!("unsupported response stream mock api kind: {other}"), + } + .to_string(); + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + planning_body.len(), + planning_body + ) + } + ResponseStreamMockPlanningResponse::Stream(planning_response) => { + let planning_body = match api_kind.as_str() { + "openai_responses" => format!( + "data: {}\n\ndata: {}\n\n", + serde_json::json!({ + "type": "response.output_text.delta", + "delta": planning_response + }), + serde_json::json!({ "type": "response.completed" }) + ), + "openai_chat" => format!( + "data: {}\n\ndata: {}\n\ndata: [DONE]\n\n", + serde_json::json!({ + "choices": [{ "delta": { "content": planning_response } }] + }), + serde_json::json!({ + "choices": [{ "finish_reason": "stop" }] + }) + ), + other => panic!("unsupported response stream mock api kind: {other}"), + }; + format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + planning_body.len(), + planning_body + ) + } + }; + { + let (mut stream, _) = listener + .accept() + .expect("response stream planning request accept"); + let planning_request = read_mock_http_request(&mut stream); + requests.push(planning_request); + stream + .write_all(planning_http_response.as_bytes()) + .expect("response stream planning response"); } - .to_string(); - let planning_http_response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - planning_body.len(), - planning_body - ); - planning_stream - .write_all(planning_http_response.as_bytes()) - .expect("response stream planning response"); if let Some(final_response) = final_response { listener @@ -3978,7 +4212,7 @@ fn run_response_stream_distinct_final_reply_case(api_kind: &str, case_name: &str let canonical_response = format!("{first_delta}{second_delta}"); let mock = spawn_response_stream_mock_llm_server( api_kind, - final_tool_plan_response(&planning_fallback), + ResponseStreamMockPlanningResponse::Stream(final_tool_plan_response(&planning_fallback)), Some(ResponseStreamMockFinalResponse::Deltas( first_delta.clone(), second_delta.clone(), @@ -4080,7 +4314,7 @@ fn run_response_stream_distinct_final_reply_case(api_kind: &str, case_name: &str .all(|request| request.contains(expected_route))); let planning_request = mock_http_request_json(&requests[0]); let final_request = mock_http_request_json(&requests[1]); - assert_eq!(planning_request["stream"], Value::Bool(false)); + assert_eq!(planning_request["stream"], Value::Bool(true)); assert_eq!(final_request["stream"], Value::Bool(true)); assert!(requests[0].contains("respond_to_user")); assert!(!requests[0].contains("\"name\":\"submit_agent_tool_plan\"")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index a531cc7fe..0d8609c1a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -2505,6 +2505,120 @@ async fn background_agent_runtime_executes_native_function_tool_plan() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_executes_streamed_native_function_tool_plan() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-stream-native-tool", "流式原生工具项目") + .expect("project init"); + let (sender, receiver) = mpsc::channel(); + let arguments = serde_json::json!({ + "reason": "读取项目索引", + "input": {} + }) + .to_string(); + let function_name = native_runtime_function_name("project.index").expect("index function"); + let base_url = spawn_mock_llm_stream_responses_with_capture( + vec![ + native_anthropic_tool_plan_response( + "call-stream-native-index", + &function_name, + &arguments, + ), + native_anthropic_tool_plan_response( + "call-stream-native-final", + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + &serde_json::json!({ + "response": "流式原生工具调用已完成聚合。STREAM_NATIVE_TOOL_OK" + }) + .to_string(), + ), + native_anthropic_text_stream_response( + "最终回复已通过独立流式收束请求生成。STREAM_NATIVE_FINAL_OK", + ), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentMode": "provider", + "agentLlm": {{ + "design-director": {{ + "apiKey": "stream-native-tool-key", + "baseUrl": {base_url:?}, + "model": "stream-native-tool-model", + "apiKind": "anthropic", + "stream": true, + "webSearchEnabled": false, + "maxRetries": 0 + }} + }} +}}"# + )); + let run_id = "design-stream-native-function-tool-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "用流式原生工具读取项目索引", + run_id, + ) + .expect("start streamed native tool task"); + + let request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("streamed native tool request"); + assert!(request.contains("POST /v1/messages HTTP/1.1")); + assert_eq!( + mock_http_request_json(&request)["stream"], + Value::Bool(true) + ); + assert!(request.contains(&function_name)); + let respond_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("streamed native respond_to_user request"); + assert!(respond_request.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); + assert_eq!( + mock_http_request_json(&respond_request)["stream"], + Value::Bool(true) + ); + let final_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("streamed native final-reply request"); + assert!(!final_request.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); + assert_eq!( + mock_http_request_json(&final_request)["stream"], + Value::Bool(true) + ); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.recent_tool_calls.len(), 1); + assert_eq!(runtime.recent_tool_calls[0].tool, "project.index"); + assert_eq!(runtime.recent_tool_calls[0].status, "ok"); + assert_eq!( + runtime.last_response.as_deref(), + Some("最终回复已通过独立流式收束请求生成。STREAM_NATIVE_FINAL_OK") + ); + let protocol_records = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.protocol" && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(protocol_records.len(), 2); + assert_eq!(protocol_records[0]["protocol"], "native_runtime_tools"); + assert_eq!(protocol_records[0]["functionCallCount"], 1); + assert_eq!(protocol_records[0]["functionNames"][0], function_name); + assert_eq!( + protocol_records[1]["functionNames"][0], + AGENT_RUNTIME_RESPOND_FUNCTION_NAME + ); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn manual_context_compaction_is_private_and_hydrates_runtime_usage() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs index 03665afb2..32a9c3ec9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs @@ -335,7 +335,7 @@ async fn response_stream_disabled_keeps_direct_planning_reply_to_one_request() { let direct_response = "非流配置直接采用 planning response,且只发起一次请求。"; let mock = spawn_response_stream_mock_llm_server( "openai_responses", - final_tool_plan_response(direct_response), + ResponseStreamMockPlanningResponse::NonStream(final_tool_plan_response(direct_response)), None, ); let base_url = mock.base_url.clone(); @@ -426,7 +426,7 @@ async fn response_stream_private_process_output_is_never_published_or_committed_ let raw_provider_response = format!("{first_delta}{second_delta}"); let mock = spawn_response_stream_mock_llm_server( "openai_responses", - final_tool_plan_response(&planning_fallback), + ResponseStreamMockPlanningResponse::Stream(final_tool_plan_response(&planning_fallback)), Some(ResponseStreamMockFinalResponse::Deltas( first_delta.clone(), second_delta.clone(), @@ -593,7 +593,7 @@ async fn response_stream_private_process_output_is_never_published_or_committed_ assert_eq!(requests.len(), 2); assert_eq!( mock_http_request_json(&requests[0])["stream"], - Value::Bool(false) + Value::Bool(true) ); assert_eq!( mock_http_request_json(&requests[1])["stream"], @@ -629,7 +629,7 @@ async fn response_stream_final_disconnect_with_retry_disabled_fails_without_comm let planning_fallback = "final stream 失败后只提交这条 planning fallback。"; let mock = spawn_response_stream_mock_llm_server( "openai_responses", - final_tool_plan_response(planning_fallback), + ResponseStreamMockPlanningResponse::Stream(final_tool_plan_response(planning_fallback)), Some(ResponseStreamMockFinalResponse::Disconnect), ); let base_url = mock.base_url.clone(); @@ -718,7 +718,7 @@ async fn response_stream_final_disconnect_with_retry_disabled_fails_without_comm ); assert_eq!( mock_http_request_json(&requests[0])["stream"], - Value::Bool(false) + Value::Bool(true) ); assert_eq!( mock_http_request_json(&requests[1])["stream"], diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index 1e3b140d1..b77477437 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -235,19 +235,33 @@ fn runtime_task_reader_accepts_rare_persisted_phases() { "failed", "public-status-write-failed", ); + // 写侧不校验 phase,只有读侧校验:白名单漏登记一个 phase,落盘之后整份 journal + // 从那一行起再也读不出来,`agent.run_status` 对该 Agent 永久失败。这里用写方 + // 引用的同一个常量,漏登记会在这条用例上先红。 + let planning_session_projection_failed = runtime_task_json_line( + "planning-session-projection-failed-run", + "failed", + AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED, + ); fs::write( &path, - format!("{brief}\n{parent_link_missing}\n{public_status_write_failed}\n"), + format!( + "{brief}\n{parent_link_missing}\n{public_status_write_failed}\n{planning_session_projection_failed}\n" + ), ) .expect("write rare persisted task phases"); let records = read_all_game_creator_agent_runtime_tasks(&path) .expect("known persisted task phases must remain readable"); - assert_eq!(records.len(), 3); + assert_eq!(records.len(), 4); assert_eq!(records[0].phase, "brief"); assert_eq!(records[1].phase, "parent-link-missing"); assert_eq!(records[2].phase, "public-status-write-failed"); + assert_eq!( + records[3].phase, + AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED + ); fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs index 62a90d607..8e470a17c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs @@ -295,8 +295,19 @@ fn validate_tool_plan_json_absolute_path_inputs( value: &serde_json::Value, duplicate_safe_json: bool, ) -> Result<(), String> { + if tool_name == crate::agent_native_tools::PLAN_SUBMIT_GDD_FUNCTION_NAME { + // plan.submit_gdd 只承载 GDD/决定文本,没有可执行的文件路径字段。 + // 其中出现的斜杠、示例路径等属于用户内容,不应按工具路径扫描。 + return Ok(()); + } let mut findings = Vec::new(); - collect_tool_plan_absolute_path_findings(root, value, None, "#", &mut findings); + if let Some(runtime_tool) = native_tool_for_handoff_function(tool_name) { + collect_native_tool_absolute_path_findings(root, runtime_tool, value, &mut findings); + } else { + // 未知工具和 legacy tool-plan 没有可信字段 schema,继续保守扫描整个 + // arguments payload,避免在无法解释参数语义时放宽路径边界。 + collect_tool_plan_absolute_path_findings(root, value, None, "#", &mut findings); + } let Some(first) = findings.first() else { return Ok(()); }; @@ -313,6 +324,189 @@ fn validate_tool_plan_json_absolute_path_inputs( )) } +fn native_tool_for_handoff_function(tool_name: &str) -> Option<&'static str> { + crate::agent::agent_runtime_native_executable_tools() + .into_iter() + .find(|tool| { + crate::agent_native_tools::native_runtime_function_name(tool).as_deref() + == Some(tool_name) + }) +} + +fn collect_native_tool_absolute_path_findings( + root: &Path, + tool: &str, + arguments: &serde_json::Value, + findings: &mut Vec, +) { + let (input, input_pointer) = arguments + .get("input") + .map(|input| (input, "#/input")) + .unwrap_or((arguments, "#")); + match tool { + "project.search" | "file.list" => { + collect_native_string_field( + root, + input, + "path", + &format!("{input_pointer}/path"), + findings, + ); + } + "file.read" | "file.write" | "file.patch" | "file.delete" => { + collect_native_string_field( + root, + input, + "path", + &format!("{input_pointer}/path"), + findings, + ); + } + "project.patchset" => { + if let Some(changes) = input.get("changes").and_then(serde_json::Value::as_array) { + for (index, change) in changes.iter().enumerate() { + let pointer = format!("{input_pointer}/changes/{index}/path"); + collect_native_string_field(root, change, "path", &pointer, findings); + } + } + } + "project.git_commit" | "image.inspect" => { + collect_native_string_array_field( + root, + input, + "paths", + &format!("{input_pointer}/paths"), + findings, + ); + } + "command.exec" | "command.start" => { + collect_native_string_field( + root, + input, + "cwd", + &format!("{input_pointer}/cwd"), + findings, + ); + if let Some(args) = input.get("args").and_then(serde_json::Value::as_array) { + for (index, argument) in args.iter().enumerate() { + let pointer = format!("{input_pointer}/args/{index}"); + collect_native_string_value(root, argument, &pointer, findings); + } + } + } + "project.verify" => { + collect_native_string_field( + root, + input, + "expectedCommand", + &format!("{input_pointer}/expectedCommand"), + findings, + ); + } + "canvas.asset_generate" => { + collect_native_string_field( + root, + input, + "outputPath", + &format!("{input_pointer}/outputPath"), + findings, + ); + } + "ui.workflow.run" => { + if let Some(pages) = input.get("pages").and_then(serde_json::Value::as_array) { + for (index, page) in pages.iter().enumerate() { + let pointer = format!("{input_pointer}/pages/{index}/applicationPath"); + collect_native_string_field(root, page, "applicationPath", &pointer, findings); + } + } + } + "task.create" => { + collect_native_string_array_field( + root, + input, + "artifacts", + &format!("{input_pointer}/artifacts"), + findings, + ); + } + "agent.delegate" => { + collect_native_string_array_field( + root, + input, + "expectedArtifacts", + &format!("{input_pointer}/expectedArtifacts"), + findings, + ); + } + "agent.spawn_isolated" => { + if let Some(children) = input.get("children").and_then(serde_json::Value::as_array) { + for (index, child) in children.iter().enumerate() { + let pointer = format!("{input_pointer}/children/{index}/writeScopes"); + collect_native_string_array_field( + root, + child, + "writeScopes", + &pointer, + findings, + ); + } + } + } + // 其它原生工具的输入是文本、ID、枚举或计数,不承载文件路径。 + _ => {} + } +} + +fn collect_native_string_field( + root: &Path, + object: &serde_json::Value, + key: &str, + pointer: &str, + findings: &mut Vec, +) { + if let Some(value) = object.get(key) { + collect_native_string_value(root, value, pointer, findings); + } +} + +fn collect_native_string_array_field( + root: &Path, + object: &serde_json::Value, + key: &str, + pointer: &str, + findings: &mut Vec, +) { + if let Some(values) = object.get(key).and_then(serde_json::Value::as_array) { + for (index, value) in values.iter().enumerate() { + collect_native_string_value(root, value, &format!("{pointer}/{index}"), findings); + } + } +} + +fn collect_native_string_value( + root: &Path, + value: &serde_json::Value, + pointer: &str, + findings: &mut Vec, +) { + let Some(value) = value.as_str() else { + return; + }; + let Some(path_shape) = tool_plan_absolute_path_shape(value) else { + return; + }; + let relation_to_root = if matches!(path_shape, "exact-absolute" | "exact-platform-absolute") { + lexical_absolute_path_relation_to_root(root, value) + } else { + "not-applicable" + }; + findings.push(ToolPlanAbsolutePathFinding { + json_pointer: pointer.to_string(), + path_shape: path_shape.to_string(), + relation_to_root: relation_to_root.to_string(), + }); +} + fn collect_tool_plan_absolute_path_findings( root: &Path, value: &serde_json::Value, @@ -461,7 +655,7 @@ fn tool_plan_absolute_path_shape(value: &str) -> Option<&'static str> { fn tool_plan_function_class(tool_name: &str) -> String { if tool_name == crate::agent::AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME { "legacy-tool-plan".to_string() - } else if let Some(tool) = super::ledger::runtime_tool_for_native_handoff_function(tool_name) { + } else if let Some(tool) = native_tool_for_handoff_function(tool_name) { format!("native:{tool}") } else { "other".to_string() diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs index 8361ddb25..23a988a2f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -797,30 +797,6 @@ fn tool_plan_handoff_reports_file_uri_and_flattened_path_shapes() { "exact-absolute" }, ), - ( - serde_json::json!({ - "reason": "修复页面", - "opaqueProviderField": "/tmp/private.html", - }), - "#/field", - if cfg!(windows) { - "exact-platform-absolute" - } else { - "exact-absolute" - }, - ), - ( - serde_json::json!({ - "reason": "修复页面", - "12345678901234567890": "/tmp/private.html", - }), - "#/field", - if cfg!(windows) { - "exact-platform-absolute" - } else { - "exact-absolute" - }, - ), ] .into_iter() .enumerate() diff --git a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs index 017142cfb..496fe0615 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs @@ -41,10 +41,42 @@ pub(crate) const AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS: usize = 2; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS: usize = 3; const AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS: usize = 64; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS: usize = 12; +/// 立项策划澄清卡的 header 是「这一轮要定的是什么」本身,不是一个 12 字的标题格。 +/// +/// 策划子 Agent 每轮都是全新 run,跨轮只能靠委派正文里转述的既往问答认路;header 带 +/// 主题时它一眼能看出哪几条轴已经关掉。原型(`local-scripts/deisgn_agent`)就是这么 +/// 做的:header 上限 60 字、写成 `第 N 轮 · 当前要决定:…`,决定台账的 `topic` 直接取 +/// 它。本仓库把 header 压成固定 8 字的轮号计数器后这条通路就断了。 +/// +/// 放宽只对 `第{N}轮` 这一种形状生效(`plan_clarification_header_limit`)。通用问询今天 +/// 能过的 header 明天逐字照过——做游戏 / 做素材两条泳道拿到的仍是 12 字上限,这里没有 +/// 任何一条既有请求会因此改变结果。 +pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS: usize = 60; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS: usize = 400; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS: usize = 60; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS: usize = 240; +/// 该 header 能用到的字符上限。 +/// +/// 判据是形状而不是身份:这个函数在通用 `user.input_request` 解析路径上,六个调用点里 +/// 有两个(工具计划校验、动作摘要)拿不到 root,问不出「这封信是不是策划链路的」。形状 +/// 判据只放宽、从不收紧——非策划 header 一律走 12 字原路,策划 header 的真正定形由 +/// `planning_coordinator::validate_exact_plan_clarification_question` 逐字兜底。 +fn plan_clarification_header_limit(header: &str) -> usize { + let Some(rest) = header.trim_start().strip_prefix('第') else { + return AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS; + }; + let rest = rest.trim_start(); + let digits = rest + .chars() + .take_while(char::is_ascii_digit) + .collect::(); + if digits.is_empty() || !rest[digits.len()..].trim_start().starts_with('轮') { + return AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS; + } + AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS +} + /// 一份 schema 合法的澄清问询在线上最多可能有多长(字符)。 /// /// 存在的意义是给中转通道一个由 schema 推导的上限,而不是让它自己拍一个数。 @@ -60,8 +92,10 @@ pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_WIRE_CHARS: usize = { let per_option = AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS + AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS + OPTION_SYNTAX_CHARS; + // 取两种 header 里宽的那个:通道窄于 schema 的后果是一封完全合法的策划信封在父 run + // 认领回执时被拒、整条委派链阻断,正是这个常量当初要防的那件事。 let per_question = AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS - + AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS + + AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS + AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS + AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS * per_option + QUESTION_SYNTAX_CHARS; @@ -314,7 +348,7 @@ fn normalize_user_input_questions( } let header = normalize_single_line_user_input_text( &question.header, - AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS, + plan_clarification_header_limit(&question.header), &format!("user.input_request question {} header", question_index + 1), )?; let question_text = normalize_single_line_user_input_text( @@ -1347,6 +1381,27 @@ mod tests { }] } + /// 放宽 header 上限只对策划澄清卡那一种形状生效,且只放宽、不收紧。 + /// + /// 两个方向都得钉:同一条 31 字的 header,带 `第N轮·` 前缀要过(策划卡装的是决定 + /// 主题本身),不带就必须照旧被 12 字挡下——否则这次改动就顺手把做游戏 / 做素材 + /// 的通用问询也放宽了,而那两条泳道本轮不该有任何行为变化。 + #[test] + fn only_the_plan_clarification_header_shape_gets_the_wider_limit() { + let long_topic = "当前要决定:一局里玩家靠什么目标获得满足"; + assert!(long_topic.chars().count() > AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS); + + let mut plan_header = valid_questions(); + plan_header[0].header = format!("第1轮·{long_topic}"); + assert!(normalize_user_input_questions(plan_header).is_ok()); + + let mut generic_header = valid_questions(); + generic_header[0].header = long_topic.to_string(); + assert!(normalize_user_input_questions(generic_header) + .expect_err("通用 header 不得因为策划分支被放宽") + .contains(&AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS.to_string())); + } + #[test] fn user_input_questions_require_unique_snake_case_ids_and_two_options() { assert!(normalize_user_input_questions(valid_questions()).is_ok()); @@ -1475,8 +1530,8 @@ mod tests { }; let question = AgentRuntimeUserInputQuestion { id: "route_choice".to_string(), - header: "第1轮·关键决定".to_string(), - question: "当前要决定:首版路线。".to_string(), + header: "第1轮·当前要决定:首版路线".to_string(), + question: "它决定第一批关卡按什么规则组合。".to_string(), options: vec![ AgentRuntimeUserInputOption { label: "接受推荐".to_string(), diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index c46441dfc..e265d1e82 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -697,7 +697,7 @@ export function App({ } catch (error) { // 方案 §18.3 要求 decision 返回后以 hydrate 对权威文件的重验为准,失败分支同样 // 适用:不重灌就会让卡片停在已失效的 pending 身份上,三个决定按钮仍可点,且 - // `recoveryPending` 永远翻不成真、「重试恢复」入口不渲染,卡内没有出路。 + // `recoveryPending` 永远翻不成真、「重试同步」入口不渲染,卡内没有出路。 // 两句顺序不能反——`hydratePlanGddState` 入口会 `setPlanGddError(null)`, // 先写错误再 hydrate 等于把这条错误擦掉。它自身从不抛出,不需要再包一层。 await hydratePlanGddState(targetProjectPath); @@ -10910,7 +10910,8 @@ export function App({ } workspaceStatus={workspaceStatus} planGddState={planGddState} - planGddHydrateBusy={planGddHydrateBusy || planGddDecisionBusy} + planGddHydrateBusy={planGddHydrateBusy} + planGddDecisionBusy={planGddDecisionBusy} planGddError={planGddError} onPlanGddRefresh={() => void hydratePlanGddState()} onPlanGddDecision={decidePlanGdd} @@ -11007,7 +11008,8 @@ export function App({ projectSupervisorRuntimeError={projectSupervisorRuntimeError} projectSupervisorTransientReply={projectSupervisorTransientReply} planGddState={planGddState} - planGddHydrateBusy={planGddHydrateBusy || planGddDecisionBusy} + planGddHydrateBusy={planGddHydrateBusy} + planGddDecisionBusy={planGddDecisionBusy} planGddError={planGddError} onPlanGddRefresh={() => void hydratePlanGddState()} onPlanGddDecision={decidePlanGdd} diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 892887c72..23474fb71 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -256,7 +256,11 @@ export interface PlanGddStateViewV1 { id: string; topic: string; state: 'confirmed' | 'default_pending' | 'prototype_pending'; - answerSource: 'user_option' | 'user_freeform' | 'default'; + answerSource: + | 'user_option' + | 'user_freeform' + | 'user_revision' + | 'default'; round: number; answerSummary: string; basis: null; diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts b/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts index 47231e90e..b8c80e344 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts @@ -189,7 +189,6 @@ function isVisibleProjectPath(localPath: string) { 'secrets', 'credentials', '.codex', - '.hermes', 'node_modules', 'target', 'dist', diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx index 0d9681e01..628f6f517 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx @@ -34,7 +34,8 @@ function planGddMarkdownDisplayPath(projectPath: string) { type GddApprovalCardProps = { state: PlanGddStateViewV1 | null; - busy: boolean; + hydrateBusy: boolean; + decisionBusy: boolean; error: string | null; onRefresh: () => void; onDecision: ( @@ -76,7 +77,8 @@ export function PlanGddSurface({ state, active = false, projectPath, - busy, + hydrateBusy, + decisionBusy, error, onRefresh, onDecision, @@ -101,7 +103,8 @@ export function PlanGddSurface({ {showCard ? ( { // 方案 §18.2:`recoveryPending` 期间只允许重试同一 ID,不允许提交决定。触发按钮 // 已经由 `canDecide` 门住,但弹层是打开后才可能被后台 hydrate 翻掉资格的, // 所以提交口要自己再判一次,不能只靠按钮 disabled。 - if (!canDecide || !commentAction || !comment.trim()) { + if (decisionDisabled || !commentAction || !comment.trim()) { return; } void onDecision(commentAction, comment.trim()) @@ -417,9 +422,9 @@ export function GddApprovalCard({ {state.recoveryPending ? (
- 审批状态正在恢复,请保持当前审批版本不变。 -
) : null} @@ -434,23 +439,23 @@ export function GddApprovalCard({
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 3dde6bd9e..1e22942f0 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -61,6 +61,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { workspaceStatus: string; planGddState: PlanGddStateViewV1 | null; planGddHydrateBusy: boolean; + planGddDecisionBusy: boolean; planGddError: string | null; onPlanGddRefresh: () => void; onPlanGddDecision: ( @@ -94,6 +95,7 @@ export function ProjectSupervisorView({ workspaceStatus, planGddState, planGddHydrateBusy, + planGddDecisionBusy, planGddError, onPlanGddRefresh, onPlanGddDecision, @@ -114,7 +116,8 @@ export function ProjectSupervisorView({ state={planGddState} active={isPlanningLaneRuntime(runtimePanelProps.runtime)} projectPath={projectPath} - busy={planGddHydrateBusy} + hydrateBusy={planGddHydrateBusy} + decisionBusy={planGddDecisionBusy} error={planGddError} onRefresh={onPlanGddRefresh} onDecision={onPlanGddDecision} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx index 0144ba194..f06471eb9 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx @@ -182,6 +182,7 @@ type ProjectWorkspaceChatPaneProps = { projectSupervisorTransientReply: string; planGddState: PlanGddStateViewV1 | null; planGddHydrateBusy: boolean; + planGddDecisionBusy: boolean; planGddError: string | null; onPlanGddRefresh: () => void; onPlanGddDecision: ( @@ -273,6 +274,7 @@ export function ProjectWorkspaceChatPane({ projectSupervisorTransientReply, planGddState, planGddHydrateBusy, + planGddDecisionBusy, planGddError, onPlanGddRefresh, onPlanGddDecision, @@ -369,7 +371,8 @@ export function ProjectWorkspaceChatPane({ state={planGddState} active={isPlanningLaneRuntime(projectSupervisorRuntime)} projectPath={projectPath} - busy={planGddHydrateBusy} + hydrateBusy={planGddHydrateBusy} + decisionBusy={planGddDecisionBusy} error={planGddError} onRefresh={onPlanGddRefresh} onDecision={onPlanGddDecision} diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts index f4a214ed6..ac8ac9143 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts @@ -1322,8 +1322,7 @@ export function resourceCanvasContentBounds( ...positions.map( (position) => position.x + - resourceCanvasCardSize(position.resourceId, cardSizeByResourceId) - .width, + resourceCanvasCardSize(position.resourceId, cardSizeByResourceId).width, ), ); const maxY = Math.max( diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeUserInputCard.test.tsx b/apps/ai-game-creator-shell/tests/agentRuntimeUserInputCard.test.tsx index cc53b7efb..8779ec102 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeUserInputCard.test.tsx +++ b/apps/ai-game-creator-shell/tests/agentRuntimeUserInputCard.test.tsx @@ -20,7 +20,7 @@ function clarificationRequest(): AgentRuntimeUserInputRequest { questions: [ { id: 'q1', - header: '第1轮·关键决定', + header: '第1轮·当前要决定:首版路线', question: '这局游戏的重玩动力是什么?', options: [ { label: '分数驱动', description: '刷新纪录后重开' }, @@ -46,7 +46,9 @@ describe('AgentRuntimeUserInputCard 澄清输入', () => { , ); - const textarea = screen.getByLabelText('第1轮·关键决定 其他回答'); + const textarea = screen.getByLabelText( + '第1轮·当前要决定:首版路线 其他回答', + ); fireEvent.change(textarea, { target: { value: '玩家自己写的答案' } }); expect((textarea as HTMLTextAreaElement).value).toBe('玩家自己写的答案'); @@ -64,7 +66,7 @@ describe('AgentRuntimeUserInputCard 澄清输入', () => { fireEvent.click(screen.getByText('分数驱动')); const textarea = screen.getByLabelText( - '第1轮·关键决定 其他回答', + '第1轮·当前要决定:首版路线 其他回答', ) as HTMLTextAreaElement; expect(textarea.value).toBe('分数驱动'); diff --git a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts index cb3e568a0..6a4ca9104 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts @@ -124,8 +124,8 @@ export function registerPlanGddApprovalTests() { hydrateCallsBeforeDecision, ); }); - // 重灌后「重试恢复」入口出现——这是 recoveryPending 下唯一被允许的动作。 - await screen.findByRole('button', { name: '重试恢复' }); + // 重灌后「重试同步」入口出现——这是 recoveryPending 下唯一被允许的动作。 + await screen.findByRole('button', { name: '重试同步' }); // 而且重灌不能把决定失败的原因擦掉:hydrate 入口会 setPlanGddError(null), // 两句顺序写反这条断言就红。 expect(screen.getByRole('alert').textContent).toContain( diff --git a/deploy/container/README.md b/deploy/container/README.md index 1de8a3489..6631905a1 100644 --- a/deploy/container/README.md +++ b/deploy/container/README.md @@ -56,7 +56,7 @@ Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `ht ## 构建工具链 -`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.7.0` 依赖链继续兼容 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。Web builder 显式安装并校验 npm `10.9.7`,再按唯一根 workspace lock 执行一次 `npm ci`,不依赖 Node 基础镜像隐含的 npm 版本。镜像构建阶段会同时复制 `public/`,用于满足 API 二进制里 `include_bytes!` 引用的内置素材;不要把 `public/generated-*` 放入镜像上下文。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。 +`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.8.3` 依赖链继续兼容 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。Web builder 显式安装并校验 npm `10.9.7`,再按唯一根 workspace lock 执行一次 `npm ci`,不依赖 Node 基础镜像隐含的 npm 版本。镜像构建阶段会同时复制 `public/`,用于满足 API 二进制里 `include_bytes!` 引用的内置素材;不要把 `public/generated-*` 放入镜像上下文。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。 ### Jenkins 预览 secrets 镜像边界 @@ -164,7 +164,7 @@ npm run container:worker-smoke -- status npm run container:worker-smoke -- smoke --force ``` -`container:worker-smoke` 默认会把本机 `spacetime` 2.7.0 CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 必须拉取官方大镜像;普通 `npm run container:*` 压测默认使用 `clockworklabs/spacetime:v2.7.0-hotfix3`(容器内二进制报告 2.7.0)。如果 Docker build 阶段在容器内拉取 crates.io 依赖不稳定,可让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入临时 smoke 镜像。该模式默认使用 `rust:1.93-bookworm` 作为 builder、Debian bookworm smoke runtime 承载构建产物;需要换 builder 镜像时设置 `GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE`,需要换运行时基础镜像时设置 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE`: +`container:worker-smoke` 默认会把本机 `spacetime` 2.8.3 CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 必须拉取官方大镜像;普通 `npm run container:*` 压测默认使用 `clockworklabs/spacetime:v2.8.3`(容器内二进制报告 2.8.3)。如果 Docker build 阶段在容器内拉取 crates.io 依赖不稳定,可让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入临时 smoke 镜像。该模式默认使用 `rust:1.93-bookworm` 作为 builder、Debian bookworm smoke runtime 承载构建产物;需要换 builder 镜像时设置 `GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE`,需要换运行时基础镜像时设置 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE`: ```bash npm run container:worker-smoke -- smoke --local-binary diff --git a/deploy/container/docker-compose.loadtest.yml b/deploy/container/docker-compose.loadtest.yml index a924e4f9d..cdb7aed22 100644 --- a/deploy/container/docker-compose.loadtest.yml +++ b/deploy/container/docker-compose.loadtest.yml @@ -2,7 +2,7 @@ name: genarrative-container-loadtest services: spacetimedb: - image: ${GENARRATIVE_CONTAINER_SPACETIME_IMAGE:-clockworklabs/spacetime:v2.7.0-hotfix3} + image: ${GENARRATIVE_CONTAINER_SPACETIME_IMAGE:-clockworklabs/spacetime:v2.8.3} user: root command: [ @@ -53,6 +53,7 @@ services: - "host.docker.internal:host-gateway" volumes: - api-tracking-outbox:/var/lib/genarrative/tracking-outbox + - api-wallet-refund-outbox:/var/lib/genarrative/wallet-refund-outbox ulimits: nofile: soft: 4096 @@ -85,6 +86,9 @@ services: OTEL_SERVICE_NAME: genarrative-external-generation-worker extra_hosts: - "host.docker.internal:host-gateway" + volumes: + - external-generation-tracking-outbox:/var/lib/genarrative/tracking-outbox-worker + - external-generation-wallet-refund-outbox:/var/lib/genarrative/wallet-refund-outbox ulimits: nofile: soft: 4096 @@ -142,4 +146,7 @@ services: volumes: spacetime-data: api-tracking-outbox: + api-wallet-refund-outbox: + external-generation-tracking-outbox: + external-generation-wallet-refund-outbox: nginx-logs: diff --git a/deploy/systemd/genarrative-database-backup.service b/deploy/systemd/genarrative-database-backup.service index 276b9f9ab..8a7d95535 100644 --- a/deploy/systemd/genarrative-database-backup.service +++ b/deploy/systemd/genarrative-database-backup.service @@ -11,6 +11,17 @@ WorkingDirectory=/opt/genarrative/current EnvironmentFile=/etc/genarrative/api-server.env ExecStart=/usr/bin/node -- /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --stop-service spacetimedb.service --restart-service-after genarrative-api.service --restart-service-after genarrative-external-generation-worker@1.service --restart-service-after genarrative-external-generation-controller.service +# 备份脚本必须受独立内存上限保护,不能因目录扫描异常拖垮整台 release 主机。 +Environment=NODE_OPTIONS=--max-old-space-size=768 +Environment=GENARRATIVE_DATABASE_BACKUP_STOP_MARKER=/var/lib/genarrative/database-backups/.spacetimedb-stopped +MemoryHigh=768M +MemoryMax=1G +OOMPolicy=stop + +# 主进程可能在停库后被 MemoryMax/OOMPolicy 强制终止,JS finally 无法执行; +# 仅当备份脚本留下停库 marker 且本次 service 非正常成功时,由 systemd 兜底恢复全部依赖服务。 +ExecStopPost=/bin/sh -c 'if [ "${SERVICE_RESULT}" != "success" ] && [ -f "${GENARRATIVE_DATABASE_BACKUP_STOP_MARKER}" ]; then systemctl start spacetimedb.service; systemctl restart genarrative-api.service; systemctl restart genarrative-external-generation-worker@1.service; systemctl restart genarrative-external-generation-controller.service; if systemctl is-active --quiet spacetimedb.service && systemctl is-active --quiet genarrative-api.service && systemctl is-active --quiet genarrative-external-generation-worker@1.service && systemctl is-active --quiet genarrative-external-generation-controller.service; then rm -f "${GENARRATIVE_DATABASE_BACKUP_STOP_MARKER}"; fi; fi' + # 备份需要停止 / 启动 spacetimedb.service,并读取 /stdb、写入 /var/lib/genarrative/database-backups。 # 停止 SpacetimeDB 会连带停止 Requires 它的 API / worker / controller,冷备份后必须显式拉起。 PrivateTmp=true diff --git a/docs/project-memory/README.md b/docs/project-memory/README.md index 6c1fb53b7..ee8ef51f8 100644 --- a/docs/project-memory/README.md +++ b/docs/project-memory/README.md @@ -1,6 +1,6 @@ # 项目记忆目录 -本目录只保存可以通过 Git 共享、并且对当前开发仍有效的项目知识。`.hermes/` 只放 Hermes 工具资源,不作为项目知识库。 +本目录只保存可以通过 Git 共享、并且对当前开发仍有效的项目知识。`.codex/` 只放 Codex 工具资源,不作为项目知识库。 ## 当前结构 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 00c219856..bf841aa67 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -30,6 +30,63 @@ - 产品边界:16 个游戏任务、六组角色、产物/验收条件、Evaluator Markdown 和中文语义路由继续留在 `platform-agent`;AGC 组合根使用公共层校验任务图与 `AgentCatalog`。Runtime store、Runner、Provider、权限、ToolHost、委派 journal、isolated write scope 和 `.agent/runtime/**` 不迁移、不双写。 - 验证方式:非游戏 conformance 覆盖并行分支、汇合、repair closure、AgentCatalog 和非法图失败关闭;`platform-agent` 锁定种子 DAG 与现役波次/返工顺序,并验证环拒绝和 catalog 注入。根检查脚本必须执行新 crate 测试。 - 关联文档:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.54。 +## 2026-08-27 `plan.submit_gdd` 拒绝无审批决定的 `user_revision` + +- 背景:结构校验允许 `round=0 + user_revision + confirmed`,提交闸原先只做结构、身份和 Session CAS。Provider 可在首次 collecting、澄清续跑或提交前质量返工里把未确认项标成用户审批修改,审批卡显示「已确认」。 +- 决策:新版本 create 时,payload 含 `user_revision` 则当前 session 的 `lastDecisionRef.action` 必须是 `revise` 或 `reject`;否则 `PLAN_INVALID_REQUEST`。同 `submissionId` replay 不重判。不恢复 session 前缀逐项相等,不把 `user_revision` 与审批意见正文对齐,也不在这次处理 `round≥1` 的 `user_option` 伪造。 +- 影响范围:`planning_submit.rs` 提交闸;Fast GDD 技术方案第 5.1 / 8.2 / 12 节。 +- 验证方式:首次 collecting 带 invented-confirmation 必须拒绝且不落 GDD;reject continuation 再交 `user_revision` 的 v2 仍成功。 +- 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`。 + +## 2026-08-28 planning continuation 必须沿当前 delivery 游标推进 + +- 背景:`lastDecisionRef.action=revise/reject` 在用户修订后的质量返工中必须继续有效,但仅凭该历史指针无法证明当前 `agent.delegate` 选择的是本次 planning session 的当前分支。 +- 决策:不新增用户修订授权字段,也不在 `plan.submit_gdd` 重复遍历 approval receipt/GDD lineage。已有 planning session 创建新 child 时,`repairOfDelegationId` 必须直接等于旧 session 的 `latestDelegationId`;不一致即在 Provider 启动前以 `PLAN_NEEDS_RECONCILIATION` 拒绝。合法用户修订及其后质量返工继续保留 `lastDecisionRef`,成功提交新的 GDD 后仍由 submit successor 清理该指针。 +- 影响范围:`planning_coordinator.rs` continuation 投影门;Fast GDD 技术方案第 8.2 节和提交步骤;不改变静态委派通用返工合同或 `PlanSessionV1` schema。 +- 验证方式:新增当前游标 continuation 正向/旧 delivery 负向回归;CI 继续验证首次伪造 `user_revision` 拒绝、用户修订后质量返工提交成功及现有澄清/返工 lineage。 +- 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs`。 + +## 2026-08-27 退款 emergency spool 容量溢出保持可恢复 +## 2026-08-27 退款 emergency spool 容量溢出保持可恢复 + +- 背景:本机 emergency spool 仅作为 SpacetimeDB 完全不可达时的最后恢复路径,原有 `MAX_BYTES` 分支会直接返回 `Dropped`,导致扣费已经完成但没有可重放记录。 +- 决策:达到普通 outbox `MAX_BYTES` 时,将退款记录写入同一持久目录的 `refund-overflow-*` 文件;该文件与普通 pending 文件一样由启动恢复和后台 worker 重放到 SpacetimeDB,且按 refund ledger id 保持幂等。溢出文件不计入普通阈值,但必须触发容量告警;底层磁盘写入失败仍进入关键退款人工补偿流程。 +- 影响范围:api-server wallet refund emergency spool、资产失败退款日志、loadtest / 预览 Compose 持久卷、后端架构与开发运维文档。 +- 验证方式:运行 api-server `wallet_refund_outbox` 定向测试,确认超限写入并保留 overflow 文件;运行 SpacetimeDB profile 测试、Compose 配置校验、编码和 diff 门禁。 + +## 2026-08-27 短期认证状态进入共享 typed projection + +- 背景:短信验证码和微信 OAuth state 仍只存在 API 进程内 HashMap,多节点请求或 API 重启会直接丢失,无法满足无粘性会话的鉴权恢复要求。 +- 决策:`AuthStoreProjectionView` 增加 `phone_codes` 与 `wechat_states` typed 字段,由 `auth_store_projection_meta` 以 JSON 投影持久化;启动恢复、CAS 同步和失败后的权威刷新都覆盖这两类短期状态。验证码哈希使用部署级稳定盐(当前复用 `GENARRATIVE_JWT_SECRET`),各 API 节点必须一致;发码前先刷新权威投影并用占位验证码记录做一次 projection CAS,只有占用成功才调用短信 provider,避免跨节点冷却竞态;认证 handler 在发码、消费验证码、创建/消费微信 state 后都要完成 projection sync,失败即返回服务错误;所有会读取或变更本机认证工作集的认证主链路(登录、刷新、`/me`、会话管理、密码、绑定和微信 state)在领域操作前先从正式投影做一次受 CAS 保护的只读刷新,受保护 Bearer 中间件也会在进入业务 handler 前执行同样的刷新,刷新失败时 fail closed,不能依赖粘性会话;同步遇到 CAS 冲突时,若本次尝试期间没有新的本地变更则恢复正式快照,若仍有待同步 revision 则由后续认证请求重试,避免节点永久卡在 pending。微信 OAuth state 设置有界活动数量,避免单个 JSON 投影无界膨胀。短期状态仍由 `module-auth` 内存工作集执行领域校验,但不再把本机 HashMap 当作持久化或跨节点真相。 +- 影响范围:`module-auth` projection、`spacetime-module` auth schema/procedure、`spacetime-client` bindings/facade、api-server 手机号 / 微信 handler、认证架构与运维文档。 +- 验证方式:运行 module-auth projection roundtrip(验证码可跨恢复校验、微信 state 可跨恢复消费)、SpacetimeDB schema/runtime/DDD 门禁、api-server 定向测试、编码和 diff 检查。 + +--- + +## 2026-08-27 外部生成历史采用受控保留清理 + +- 背景:`external_generation_job`、`external_generation_job_summary` 与 `external_generation_job_event` 都是持久化表;摘要和 payload 边界收紧后,已确认的终态历史仍会继续占用 SpacetimeDB 常驻内存,且事件审计链会随任务数量增长。 +- 决策:新增仅 migration operator 可调用的 `prune_external_generation_job_history_and_return`。默认按 `source_module=editor-canvas`、30 天保留期和 `job_id` 游标分批运行;只删除主任务与摘要状态一致、属于 completed / failed / cancelled、摘要已有 `notification_acknowledged_at` 且终态时间达到 cutoff 的任务。事件、摘要和主任务仍按同一事务顺序删除,但每次事务最多删除 256 条事件;事件未删完时保留任务与摘要并返回同一个 job cursor,维护脚本下一次继续,避免单个任务形成无界事务写集。默认 dry-run,必须固定 dry-run 返回的 cutoff 后再 apply;pending / running、未确认通知、摘要缺失或状态不一致的数据永不删除。其他 source module 必须显式指定并单独评估;资产对象和钱包流水不随任务历史删除;不新增自动定时器或 runtime 清理权限。 +- 影响范围:`server-rs/crates/spacetime-module/src/external_generation.rs`、外部生成事件 job_id 单列索引、SpacetimeDB 生成 bindings、`scripts/spacetime-maintain-external-generation-jobs.mjs`、架构与生产运维文档。 +- 验证方式:覆盖终态 / 活跃态 / 已确认与未确认摘要、状态或身份不一致、cutoff 边界测试;运行 SpacetimeDB module tests/check、bindings 生成、schema/encoding/diff 门禁,并在维护窗口先 dry-run 再 apply。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、PR #203。 + +## 2026-08-27 SpacetimeDB 工具链统一升级到 2.8.3 + +- 背景:SpacetimeDB 2.8.0 引入 TypeScript submodule 与调度延迟观测,2.8.1 修复 v1 WebSocket 订阅移除死锁、TypeScript SDK `array` 读缓存别名和 Rust string 默认值支持,2.8.2 修复 table accessor 改名自动迁移,2.8.3 修复 scheduled function 从实际执行时间重排导致的长期漂移。仓库若继续锁定 2.7.0,会保留这些已知运行时与 SDK 问题。 +- 决策:`server-rs/Cargo.toml` 的 `spacetimedb`、`spacetimedb-sdk`、`spacetimedb-lib` 精确锁定 2.8.3;本地 CLI / standalone、Rust bindings、worker smoke 本地镜像、官方容器压测镜像和生产 provision 下载根同步对齐 `v2.8.3`,CLI / standalone commit 门禁为 `8e410d28...`。2.8.3 不再使用 2.7.0 的 hotfix3 特殊资产标签口径,但同版本 commit 校验继续保留。 +- 影响范围:Rust workspace lockfile、SpacetimeDB bindings、本地 dev 版本门禁、容器 smoke / loadtest、server provision Jenkins 与项目 SpacetimeDB skills / 文档;现役 module 未使用 submodule,本次不修改 schema 或 migration。 +- 验证方式:核对 CLI 版本和 commit,重新生成 Rust bindings,运行 `npm run check:spacetime-schema`、相关 Cargo check / tests、server provision 工具测试、dev 调度测试、encoding 和 diff 门禁。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 2026-08-26 Fast GDD 修订后先取证再允许再次委派 + +- **现象**:GDD v1 经用户选择“修改”后,策划子 Agent 正确提交 v2,但 plan 根 Supervisor 的 `Delegated` 阶段仍同时广告 `agent.delegate` 与审批前置工具;模型可能在 Acceptance Graph 重新取证前重复创建修订 delivery,随后被 `PLAN_PROVIDER_USAGE_DEFERRED` 拦停。 +- **决策**:plan 根阶段增加轻量的 `AwaitingAcceptanceEvidence` 状态。当前根最新 GDD 无 approval receipt/pending、session `latestSubmittedRef` 精确指向该提交、delivery 已由根认领且 Acceptance Graph 返回 `NeedsEvidence` 时,只广告 `file.read`、`agent.acceptance_update`、`agent.run_status`;只有用户真正对最新审批卡选择修改/退回后,才恢复 `agent.delegate`。 +- **边界**:不放宽 Provider usage 门禁,不重构 delegation/repair lineage,不自动生成证据或审批 pending;审批 pending 仍只由既有 acceptance gate 在 `agent.acceptance_update` 成功后创建。 +- **验证**:新增一条阶段工具面回归,并通过 15 条 M1C-2a acceptance gate 定向测试、plan root 原生工具目录测试、`cargo check --all-targets`、格式与 diff 检查。 +- **锁边界修正(2026-08-27)**:阶段判定拆为 `plan_root_supervisor_stage_at_locked` 与负责取得一次项目锁的外层入口;Provider tool-plan builder 已持有项目锁时直接复用 locked 入口。Acceptance Evidence 判据和阶段工具面不变,禁止在持锁调用链中再次获取 `.agent/project.lock`。 +- **回归验证**:planning submit 定向测试 68 passed、Provider request builder 定向测试 17 passed、Tauri `cargo check` 与 `cargo fmt --check` 通过。 ## 2026-08-24 AGC Direct 媒体能力只通过客户端语义工具开放 @@ -1324,8 +1381,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - OSS 固定恢复入口为 `//latest.json`。CAS 文件和 full/history catalog 保持不可变;latest pointer 只保存最新 full catalog 与已发布 history catalog 的 object key、长度和 SHA,不包含主机绝对路径或文件内容。每次 state 变化先验真全部引用 catalog,再覆盖上传并 HEAD 验真 latest pointer,成功后才落本地 state;history 还必须在 pointer 成功后才允许删除源文件。全新机器可仅凭 bucket、database、prefix 与 OSS 凭据自动下载 pointer 和 full catalog。 - dev 带宽不足时,允许把已冻结的 dev 基线经 `10.2.0.10 -> 10.2.4.16` 内网 rsync 到 release 独立 staging,再用 release 出口上传 dev bucket;staging 不得指向 release `/stdb`,不得停止或修改 release 服务,传输凭据必须临时创建并在演练后移除。catalog 不记录 staging 绝对路径,files state 可回传 dev 继续 history。 - 恢复边界:恢复时默认从 OSS `latest.json` 自动定位 full catalog,创建目录并按相对路径下载每个对象、逐文件校验长度与 SHA;本地 state 只用于备份续跑,不再是异机恢复前置条件。远程 dev 已完成真实 OSS、清理、重启和异机隔离恢复演练;release timer 与 publish 前备份继续保持原行为。 -- systemd 接线:主 service 保持 `archive-full`。Server-Provision 新增默认值为 `archive-full` 的 `DATABASE_BACKUP_PROFILE`;dev 或 release 显式选择 `files-history` 时,必须为各自主机指定独立 work-dir,并先用 current release 脚本执行 history dry-run,确认已有 full state 后才安装仓库托管 drop-in,并删除现场手写旧 drop-in。切回默认 profile 必须删除所有 history 覆盖。 -- 影响范围:`scripts/database-backup-to-oss.mjs`、备份门禁、生产 env 示例、systemd 模板、Server-Provision、SpacetimeDB 运维与恢复流程;release timer 可在独立 baseline 验证后显式选择 profile,publish 前备份是否切换仍需单独决策。 +- systemd 接线:主 service 保持 `archive-full`。Server-Provision 新增默认值为 `archive-full` 的 `DATABASE_BACKUP_PROFILE`;development 可显式选择 `files-history`,必须指定独立 work-dir 并先用 current release 脚本执行 history dry-run,确认已有 full state 后才安装仓库托管 drop-in;release 拒绝 `files-history`,直到流式 catalog 改造完成,以免大目录扫描再次触发 Node 内存峰值。切回默认 profile 必须删除所有 history 覆盖;备份 unit 同时设置 Node heap 与 systemd memory 上限,避免备份异常拖垮业务主机。 +- 影响范围:`scripts/database-backup-to-oss.mjs`、备份门禁、生产 env 示例、systemd 模板、Server-Provision、SpacetimeDB 运维与恢复流程;release timer 固定使用 archive-full,publish 前备份是否切换仍需单独决策。 - 验证方式:`npm run check:database-backup`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`;dev 现场必须完成逐文件 full catalog、重复 full 零 PUT、history dry-run、上传后清理、STDB 重启和按 catalog 隔离恢复 roundtrip。 - 关联:。 @@ -2192,13 +2249,15 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:微信小程序首点登录仍打开原生登录页;小程序支付仍跳转 `/pages/wechat-pay/index` 并保留 hash 回灌确认;订阅授权仍跳转 `/pages/subscribe-message/index` 且返回不阻断生成;普通浏览器分享、H5 支付和 Native 二维码支付不受影响。前端验证运行 HostBridge、auth、payment、分享、订阅和个人中心充值相关定向测试,并执行 `npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 -## 2026-06-15 SpacetimeDB 本地 skills 只保留 CLI / Concepts / Rust +## 2026-06-15 SpacetimeDB 本地 skills 范围(已由 2026-08-27 决策覆盖) + +> 2026-08-27 覆盖说明:本节记录的“三个本地 skill”方案已收敛为单一项目适配层;当前口径见下方“SpacetimeDB 项目 skill 与官方插件职责收敛”。 - 背景:本仓库的 SpacetimeDB 接入已固定为 `server-rs + Axum + SpacetimeDB`,本地 skill 需要从上游 SpacetimeDB `skills/` 更新到 2.5 口径,同时避免继续维护当前项目不使用的 TypeScript server/client、C# 和 Unity 专用 skill。 -- 决策:`.codex/skills/` 下只保留 `spacetimedb-cli`、`spacetimedb-concepts`、`spacetimedb-rust` 三个本地 SpacetimeDB skill;删除 `spacetimedb-typescript`、`spacetimedb-csharp`、`spacetimedb-unity`。前端 / Node 侧如需处理 SpacetimeDB 订阅或绑定,按当前生成绑定、项目代码和官方文档核对,不再依赖仓库内单独 TypeScript skill。 -- 影响范围:`AGENTS.md` 的 SpacetimeDB skill 清单、`.codex/skills/` 本地 skill 维护范围、后续 SpacetimeDB 设计 / CLI / Rust module 开发协作口径。 -- 验证方式:用上游 `clockworklabs/SpacetimeDB@master` 的 `skills/` 目录对照,运行本地 skill 校验、删除引用扫描、`git diff --check -- .codex/skills AGENTS.md .hermes/shared-memory/decision-log.md` 和 `npm run check:encoding`。 -- 关联文档:`AGENTS.md`、`.codex/skills/spacetimedb-cli/SKILL.md`、`.codex/skills/spacetimedb-concepts/SKILL.md`、`.codex/skills/spacetimedb-rust/SKILL.md`。 +- 决策:当时仅在仓库内维护与当前后端路线相关的 SpacetimeDB skill,通用 SDK/CLI 内容按上游资料核对;该历史范围已由 2026-08-27 的项目适配层方案替代。 +- 影响范围:当时的 `AGENTS.md` SpacetimeDB skill 清单和本地 skill 维护范围;当前范围以新的项目适配层及官方插件路由为准。 +- 验证方式:保留当时的上游 skill 对照、本地 skill 校验、删除引用扫描、diff 和编码检查记录。 +- 关联文档:`AGENTS.md`、`.codex/skills/genarrative-spacetimedb/SKILL.md`、`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`。 ## 2026-06-13 图片大图预览统一为黑底全屏查看器 @@ -3413,9 +3472,9 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 背景:新增玩法的创作工具如果默认复制既有玩法的聊天式 Agent、轻输入 Agent 或专属素材模型,平台会不断复制出不可控分支,后续接入、测试和恢复语义都会漂移。 - 决策:新增玩法创作工具统一收敛为平台级 SOP:默认使用表单/图片输入创作工作台;单图资产统一通过 `CreativeImageInputPanel`;系列素材统一走批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成流水线;不把任一玩法专属素材模型当平台通用模型。 -- 影响范围:`CONTEXT.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`.codex/skills/genarrative-play-type-integration/SKILL.md`、`.hermes/skills/genarrative-play-type-integration/SKILL.md`、后续新增玩法 PRD 和工程实现。 +- 影响范围:`CONTEXT.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`.codex/skills/genarrative-play-type-integration/SKILL.md`、后续新增玩法 PRD 和工程实现。 - 验证方式:新增玩法 PRD 必须显式声明单图资产槽位和系列素材槽位;新增工作台测试确认没有默认聊天式 Agent 输入;skill 通过 `quick_validate.py`。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`.codex/skills/genarrative-play-type-integration/SKILL.md`、`.hermes/skills/genarrative-play-type-integration/SKILL.md`。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`.codex/skills/genarrative-play-type-integration/SKILL.md`。 ## 2026-05-20 敲木鱼玩法按完整平台纵切接入 @@ -3990,13 +4049,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:VN 定向前端测试、`npm run typecheck`、`npm run check:encoding`、`cargo test -p api-server visual_novel`、`cargo test -p api-server creation_agent_document_input`。 - 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`。 -## 2026-05-04 在仓库 `.hermes/` 中建立团队共享记忆 +## 2026-05-04 建立仓库级项目知识与工具边界 -- 背景:团队有 3 名开发人员,均在各自本地安装 Hermes,并需要独立拉取仓库、修改代码、本地测试;团队希望形成共享的长期项目记忆。 -- 决策:不共享个人 `~/.hermes`,先在 Genarrative 仓库内使用 `.hermes/` 保存可 Git 同步的团队共享记忆、计划和未来 skills。 -- 影响范围:`AGENTS.md`、`.hermes/README.md`、`docs/project-memory/shared-memory/`。 -- 验证方式:任一开发者拉取仓库后,在项目根目录启动 Hermes,均可读取同一套 `docs/project-memory/shared-memory/` 文件。 -- 关联文档:`.hermes/README.md`、`docs/project-memory/shared-memory/team-conventions.md`。 +- 背景:团队有 3 名开发人员,需要独立拉取仓库、修改代码和本地测试,同时共享稳定的项目知识与工具约定。 +- 决策:长期项目知识统一保存在 `docs/project-memory/`;仓库内 `.codex/` 仅保存可 Git 同步的 Codex skills、插件资源、hooks 和配置模板;个人 `~/.codex` 始终保持本机私有。 +- 影响范围:`AGENTS.md`、`.codex/README.md`、`docs/project-memory/shared-memory/`。 +- 验证方式:任一开发者拉取仓库后,先读 `AGENTS.md`,即可按入口读取同一套 `docs/project-memory/shared-memory/` 和 `.codex/skills/`。 +- 关联文档:`.codex/README.md`、`docs/project-memory/shared-memory/team-conventions.md`。 ## 2026-04-25 后端唯一落地口径固定为 Rust / SpacetimeDB @@ -5869,7 +5928,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:Linux `command.exec / command.start / project.verify` 的安全事实源从固定 program / argv 白名单或平行 npm spawn 升级为同一个 bubblewrap OS sandbox launcher。approval policy 继续决定是否确认,sandbox 独立限制文件系统和网络;普通 confirm 永远不能扩大 sandbox。 - 决策:Linux 只允许受信任系统 bubblewrap,缺失、权限异常或 namespace setup 失败必须在项目命令执行前失败关闭,不用裸 userns、代理变量或宿主全权限回退。当前机器 bubblewrap 0.11.1 已通过真实 namespace smoke,裸 userns 因 AppArmor uid_map 限制不可作为可靠 fallback。 -- 决策:项目根可写,`.git / .agents / .codex / .hermes` 只读,`.agent` 隐藏且不可写,项目外普通用户文件不挂载,network namespace 默认隔离;HOME / TMP / cache 使用 sandbox 私有目录,所有 shell、PTY 和后代继承同一边界。 +- 决策:项目根可写,`.git / .agents / .codex` 只读,`.agent` 隐藏且不可写,项目外普通用户文件不挂载,network namespace 默认隔离;HOME / TMP / cache 使用 sandbox 私有目录,所有 shell、PTY 和后代继承同一边界。 - 决策:Linux sandbox 生效后,program 扩展为受信任 PATH 中的裸可执行名,argv 仅保留结构长度与控制字符门禁,允许 shell 管道和项目脚本;Windows 在等价原生 sandbox 落地前继续使用 V1.10 固定白名单与 Job Object,不能宣称通用命令或 Codex CLI 级隔离。 - 验收门禁:项目内构建 / 测试 / Git 读取成功;项目外读写、控制目录写入和网络访问失败;子进程与 PTY 会话继承相同边界;bubblewrap 不可用时零项目命令执行。真实 Provider 还需在无固定命令配方下自行发现并运行项目命令。 - 审计与发布:process record v2 保存 launch 当时的 backend / mode / network / profile,后续 process 工具从 durable/live 身份读取,preflight 失败使用 unavailable / not-established,不能按平台静态宣称已建立。共享 `os-workspace-sandbox` capability 只标记 Linux;deb / rpm 声明 bubblewrap 依赖,AppImage 依赖宿主预装并保持 fail-closed。 @@ -7750,6 +7809,20 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 安全:DirectProject 使用真实 `game/` writable root、`approvalPolicy=never`,原生命令网络保持关闭,联网资料继续走受控 `agc_web_search`;Codex 子 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 与未审计浏览器/电脑控制继续关闭。配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只留在 AGC 本地代理;前者仍走已配置上游,后者只走 OpenAI 官方 API,Codex 仅获得连接级随机代理令牌。无法安全代理的 OAuth `auth.json` 继续关闭原生 shell/unified exec。app-server 使用隔离 `CODEX_HOME`,shell 用 `shell_environment_policy` glob 排除 provider key、proxy、loopback bridge 和受控开关。 - 上下文:Direct 系统提示词只保留身份、cwd、边界和 Skill 索引;不再预注入项目源码快照、项目提示词或 Skill 正文。浏览器工具回传结构化事实,不强制固定三次整改循环;Codex 自行解释证据并决定是否继续。sandbox writableRoots 不提供 deny-read,`.agent`/`../assets` 的不可读约束需靠行为合同和真实 smoke 验证。 +## 2026-08-27 GDD 修改后历史 receipt 不得污染当前审批恢复 + +- 现象:GDD“修改”已成功生成下一版本且当前 pending 身份正确,但 hydrate 持续返回 `recoveryPending=true`,审批卡显示“审批状态正在恢复”。 +- 原因:恢复扫描会重放全部历史 approval receipt;旧版本 receipt 仍拿当前单例 approval pending 做 identity 比对。修改后当前 pending 已属于新版本,旧 receipt 的 identity 不同是正常状态,却被误记为投影缺口。 +- 决策:receipt 的 index、Markdown、audit、submit observation、session 等投影继续允许全量恢复;approval pending 只由 lineage 最新 GDD 的 receipt 读取、更新和清理。历史 receipt 不得检查或改写当前 pending,也不得因此提升 `recoveryPending`。 +- 审批意见消息按 receipt 的 `rootRunId` 解析到原 Supervisor task 所属会话恢复;不会按当前 active session 重新路由。已存在于归档会话的幂等消息允许重放且不新增消息,缺失消息仍保持恢复失败,不静默写入其他会话。 +- 验证:沿用现有审批恢复与 planning submit 定向测试;未新增独立测试,避免为非代表性 fixture 引入额外状态构造。 + +## 2026-08-27 审批修订以最新用户意见更新 GDD 决定快照 + +- `decisions` 表示当前 GDD 版本的决定快照,不再作为新提交必须逐项复制的 session 历史前缀。审批修订可以修改、推翻、删除或新增决定;Runtime 只校验结构、身份、CAS、版本和原型验证项双射,不做自然语言修改范围门禁。 +- 新增 `answerSource=user_revision`,用于标记来自审批修改意见的当前决定,按 `round=0` 记录;`default` 仍只表示未提问的默认建议,澄清来源仍使用 `user_option` / `user_freeform`。 +- planning Prompt 约束为:以当前 GDD 为基线,仅修改用户意见明确涉及的内容及保持内部一致性所必需的派生内容,未涉及内容保持不变;意见与旧决定冲突时以最新意见为准。 + ## 2026-08-24 AGC UI 原型桥接与自主 UI workflow - 决策:`ui-prototype` 图片与 `UI` JSON 编辑资源保持两种正式类型。Agent 通过受控 `ui.workflow.run` 按 `prepare -> recognize -> status -> finalize` 创建页面资源、关联源图、持久化 UI State 和 manifest 阶段;`recognize` 直接复用 UI Editor 的 provider-backed 结构识别、多树合并与组件绑定命令,按 `reference-ready -> structure-ready -> merge-ready -> binding-ready` 逐阶段写入并推进项目 revision。页面可显式关联已登记图片/图标和字体,图片/图标按 5 项一批绑定,字体安全元数据进入绑定上下文且未知引用失败关闭。Runtime 回执携带 `revisionAdvanceCount`;Provider 未配置、请求失败、工具调用缺失、结果不匹配、未产出可渲染组件或仍有待审节点时保留最近真实阶段,禁止用 deterministic seed 冒充语义处理完成。 @@ -7762,3 +7835,23 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - DirectProject 的 Codex cwd 固定为真实 `game/` 目录时,原生文件工具和 patch 必须使用 cwd 相对路径(`index.html`、`style.css`、`game.js`);`game/...` 仅用于 AGC manifest、回执和客户端投影,不能作为 cwd 内原生 patch 路径,以避免 `writing outside of the project`。 - 直连 Runtime 已取得 Developer Key 时,资源编辑的 `remote_credentials` 是该操作的完整身份边界;其中冻结平台快照为空表示 Developer 模式,禁止再从进程全局 GUI 登录态补回账号快照。平台账号模式仍只使用同一组凭据捕获的快照。 - 回归覆盖 Direct 系统提示路径合同和 Developer Key / GUI 快照隔离;未触碰用户项目 `.agent` 锁、账本或凭据。 + +## 2026-08-27 SpacetimeDB 项目 skill 与官方插件职责收敛 + +- 决策:`.codex/skills/` 下的 SpacetimeDB 指导收敛为单一 `.codex/skills/genarrative-spacetimedb/SKILL.md`。官方 `spacetimedb` 插件负责通用 concepts、Rust server、CLI、TypeScript client 和 MCP 知识;项目 skill 只保留 Genarrative 的架构边界、schema/migration 门禁、目标 server 安全规则、运行时排障和验证路径。 +- 路由:涉及 SpacetimeDB 的任务统一先读取项目适配 skill,再按需读取 `spacetimedb:concepts`、`spacetimedb:rust-server`、`spacetimedb:cli`、`spacetimedb:typescript-client` 或 `spacetimedb:mcp`。插件通用示例不得覆盖项目禁止 `maincloud`、禁止人工 `spacetime --root-dir`、显式 server 和后端分层等约束。 +- 安装:团队环境缺少插件时使用 `codex plugin marketplace add clockworklabs/SpacetimeDB --sparse .agents --sparse codex-plugin` 和 `codex plugin add spacetimedb\@spacetimedb-plugins`;个人配置、缓存和凭据不进入仓库。 + +## 2026-08-27 退款 outbox 主路径迁入 SpacetimeDB + +- 决策:`profile_wallet_refund_outbox` 是跨 API 节点退款的正式持久化队列。扣费失败、外部生成 attempt 失败或最终 lease 过期时,在同一个 SpacetimeDB 事务内按 `refund_ledger_id` 幂等写入 pending 行;worker 从库内 pending 行批量处理,退款账本写入与 outbox 成功删除保持在同一事务内,失败由 `available_at` / `attempts` 驱动重试。`asset_operation_wallet_settlement` 继续负责退款先于 consume 可见时的取消 intent,阻止迟到扣费。只有 SpacetimeDB 完全不可达时,api-server 才写本机 `wallet-refund-outbox` emergency spool;本机文件不能替代库内队列,必须持久挂载、告警、恢复演练并支持人工补偿。 +- 影响范围:`profile_wallet_refund_outbox` 表及 bindings、runtime enqueue/process procedure、外部生成失败事务、inline 资产退款、api-server 跨节点 worker 和 emergency spool、后端架构与开发运维文档。 +- 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:server-rs-ddd`、`cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`、退款 outbox / asset billing / external generation 定向测试、`npm run check:encoding` 和 `git diff --check`。 + +## 2026-08-27 release 内存增长修复与备份 OOM 恢复兜底 + +- 决策:外部生成 worker 每轮主动 `try_join_next` 回收已完成 `JoinHandle`,避免持续有队列任务时只归还 semaphore permit 却让 `JoinSet` 句柄集合无界增长;超时脱管任务在 abort 后等待句柄结束,执行许可保持到 work 真正结束或被取消。 +- 决策:`module-ai` 的阶段终态写入与流式增量统一受文本、结构化 JSON、warning 和全局 retained 工作集上限约束;认证投影恢复对过滤后的 refresh session 重新计数,超过 8192 条直接拒绝启动恢复,避免超限快照灌入内存。 +- 复审补充:`spacetime-module` 的 AI procedure 复用同一组任务元数据 / payload / 输出 / 结果引用上限,流式文本聚合超过 512 KiB 或每阶段 8192 个 chunk 时回滚事务,terminal task 收口后按有界批次删除 `ai_text_chunk` 明细,避免真实持久化链路绕过内存边界。 +- 决策:备份脚本停库前写入受保护 `.spacetimedb-stopped` marker,正常恢复完成后清理;systemd 备份 service 通过 `MemoryHigh/MemoryMax/OOMPolicy` 和 `ExecStopPost` 在 Node OOM kill、无法执行 JS finally 时兜底拉起 SpacetimeDB、API、worker、controller,恢复不完整则保留 marker。 +- 验证:worker/module-auth/module-ai 定向 Rust 测试、database-backup/production-ops/encoding 门禁和 `git diff --check` 必须在提交前通过;release 现场需按 archive-full 重新 provision 并核验旧 files-history drop-in 已删除、四个服务 active。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 3be0c4b05..34ec431a4 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -1,6 +1,6 @@ # 开发工作流 -更新时间:`2026-08-25` +更新时间:`2026-08-27` ## 标准流程 @@ -8,6 +8,8 @@ 确认工作树与目标分支 → 读取入口和当前专题 → 查代码真相 → 小步修改 → 定向验证 → 更新当前文档/记忆 → 检查提交边界 ``` +任务开始时先写清一句话交付结果、验收判据和不做项,再按“必须项 / 风险项 / 可选项”排序。先完成修改、定向验证和边界检查组成的最小闭环;设置时间盒和检查点,新增发现只有在影响交付判据时才扩大范围,否则记录为后续事项。不要让工具探测、历史整理或验证便利自行改变任务目标。 + ## 开始前 - 运行 `git status --short`,保留用户已有的未提交修改;不要在共享工作树中使用破坏性 Git 命令。 @@ -35,6 +37,8 @@ ## 验证路由 +SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.md`;该项目适配层按需调用已安装的官方 `spacetimedb` 插件 skill,插件提供通用 SDK/CLI/MCP 知识,项目 skill 负责 Genarrative 架构边界和验证门禁。 + 按改动范围选择定向门禁,不以无关全量扫描代替契约验证: | 范围 | 至少运行 | diff --git a/docs/project-memory/shared-memory/handoff-template.md b/docs/project-memory/shared-memory/handoff-template.md index e7f0681a3..01ad2101b 100644 --- a/docs/project-memory/shared-memory/handoff-template.md +++ b/docs/project-memory/shared-memory/handoff-template.md @@ -1,6 +1,6 @@ # 任务交接模板 -> 用途:当一名开发者把任务交给另一名开发者,或让 Hermes 接续上下文时,复制本模板并填写。 +> 用途:当一名开发者把任务交给另一名开发者,或让 Codex 接续上下文时,复制本模板并填写。 ## 基本信息 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index cf3ddfd0f..bdf1d0eb5 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,27 @@ > 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。 +## 2026-08-27 Provider 成功 handoff 失败时需要保留本地私有原始响应 + +- **现象**:Provider 已返回响应,但 tool-plan handoff 因绝对路径或其它内容安全校验失败,Runtime 只留下 `failureKind`、哈希和被压平的 JSON pointer;排障时无法确认实际工具名和完整 arguments。 +- **处理**:项目 `.agent`、Agent DB 和公共 event 继续只写安全摘要;额外在应用私有配置目录的 `diagnostics/provider-reconciliation//.json` 保存本次响应、tool calls 和校验错误,供本机人工排障。该文件不参与恢复/重试、不复制到项目、不进入 Git,单文件限制 1 MiB,写入失败不改变 reconciliation 语义。 +- **排查顺序**:先读 Runtime 状态里的 `localDiagnostic` 相对引用,再在应用私有目录读取诊断,核对 requestId、requestSlot、tool name 和失败 pointer;不要为了取得原文而放宽 handoff 的安全门。 + +## 2026-08-27 阶段判定不能在持锁的 Provider builder 中再次获取项目锁 + +- **现象**:GDD 修订取证阶段新增后,重新启动策划时前两步表面成功,但父 Supervisor 在收到 `project-planning` 回执、生成下一轮工具计划时失败:`项目正在被其他写操作占用:$PROJECT_ROOT\\.agent\\project.lock`。 +- **原因**:`provider_tool_plan` 在构建请求前已持有 `.agent/project.lock`;`plan_root_supervisor_stage_at` 又调用会自行取锁的 Acceptance Evidence 包装入口。同一进程的文件锁不可重入,持锁调用被误判为外部竞争,等待约 10 秒后失败。问题与 Provider、代理端口或 GDD 内容无关。 +- **处理**:所有需要一致快照的状态读取保留在项目锁内;阶段判定提供明确的 `*_locked` 内部入口,外层入口仅供未持锁调用方取得一次锁。Provider builder 显式接收并校验当前锁后调用 locked 阶段判定,不引入可重入锁,也不移除 Acceptance Evidence 门禁。 +- **排查顺序**:先看失败 Run 的事件顺序是否为 `delegate receipt ready → 生成工具计划 → 阶段判定项目锁失败`,再检查调用方是否已持有 Provider plan project lock;不要因为错误文案包含“其他写操作”就先扩大锁等待或放宽 Provider usage。 +- **验证**:`cargo check`、`cargo fmt --check`、planning submit 68 passed、Provider request builder 17 passed;阶段测试同时覆盖未持锁包装入口和持锁 locked 入口。 + +## 2026-08-26 GDD 新版本提交后不能沿用“已有委派”工具面 + +- **现象**:`plan_root_supervisor_stage_at` 只按是否存在 delivery 判定 `Delegated`。用户修订产生的新 GDD 仍未完成当前根 Run 的 `file.read → agent.acceptance_update` 取证时,模型会看到 `agent.delegate`,可能重复派发同一条策划链。 +- **原因**:自然语言 playbook 已规定“证据不足先取证、用户修改后才返工”,但阶段工具白名单没有把这条 durable 状态固化。 +- **处理**:阶段判定复用现有 acceptance gate 的 GDD/session/delivery/graph identity 检查,增加无副作用的 `AwaitingAcceptanceEvidence` 阶段;`PLAN_PROVIDER_USAGE_DEFERRED` 保持 fail-closed,不通过放宽 Provider 使用量门禁解决。 +- **排查顺序**:先看最新 `gdd.vN.json`、`session.latestSubmittedRef`、delivery 是否 `ClaimedByParent`,再看 Acceptance Graph 是否 `NeedsEvidence`;若仍可见 `agent.delegate`,优先检查 plan root 阶段快照,而不是修改 acceptance gate 或 Provider 门禁。 + ## 2026-08-15 把校验往链路前面挪,改的不是严格程度而是作用域 - 现象:CI 全量 5 条失败,看上去毫不相干(两条 Goal 续跑停在 `needs-reconciliation`、一条交接用例断言错误文案、一条恢复用例把不可读 state 的错误抛了出来、一条 Linux-only 用例错误码对不上),实际只有 3 个根因,且三者是**同一个形状**:新增或既有的检查被放在了链路更靠前的位置,于是它的语义作用域被悄悄放大或提前,而不是「变严」。 @@ -1251,7 +1272,7 @@ - 原因:邀请码流程迁移后仍按新用户窗口保留 `canShowReferralRedeemShortcut` 次级入口;但当前页面口径已经固定为五项常用功能宫格,邀请码填写应由邀请链接 query 或明确引导打开弹窗。 - 处理:移除常驻 `次级入口` / `填邀请码` 渲染,不删除 `ProfileReferralModal` 的 `redeem` 面板,也不破坏 `?inviteCode=` / `?invite_code=` 自动打开填写弹窗。 - 验证:新用户账号打开“我的”页时没有 `次级入口` 和 `填邀请码` 按钮;带 `?inviteCode=spring-2026` 的登录用户仍自动打开邀请码弹窗并预填 `SPRING2026`。 -- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`.hermes/skills/genarrative-profile-invite-flow/SKILL.md`。 +- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`.codex/skills/genarrative-profile-invite-flow/SKILL.md`。 ## 创作卡片点击要直达已有入口表单,别再保留空白入口页 @@ -1914,13 +1935,13 @@ - 验证:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`;`npm run typecheck`。 - 关联:`src/components/match3d-result/Match3DResultView.tsx`、`src/components/match3d-result/Match3DResultView.test.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 -## `.hermes` 只放共享内容,不放个人 Hermes 配置 +## `.codex` 只放项目工具,不放个人 Codex 配置 -- 现象:团队成员误把个人 Hermes 配置、会话或密钥复制进仓库。 -- 原因:仓库 `.hermes/` 与个人 `~/.hermes/` 名称相似。 -- 处理:仓库 `.hermes/` 只放 Markdown 共享记忆、计划和可公开 skills;不提交 `.env`、`config.yaml`、`sessions/`、`auth.json`。 -- 验证:提交前检查 `git diff -- .hermes`,确认没有密钥、会话记录或个人路径敏感信息。 -- 关联:`.hermes/README.md`。 +- 现象:团队成员误把个人 Codex 配置、会话或密钥复制进仓库。 +- 原因:仓库 `.codex/` 与个人 `~/.codex/` 名称相似。 +- 处理:仓库 `.codex/` 只放可公开的 skills、插件资源、hooks 和配置模板;长期项目知识写入 `docs/project-memory/`,不提交 `.env`、`config.toml`、`sessions/`、`auth.json`。 +- 验证:提交前检查 `git diff -- .codex`,确认没有密钥、会话记录或个人路径敏感信息。 +- 关联:`.codex/README.md`。 ## 儿童动作 Demo 卡在摄像头不可用或挥手不推进先查 mocap 消费链路 @@ -3306,7 +3327,7 @@ - 原因:Vite 可能来自当前 worktree,但代理目标的 `api-server` 仍是另一个 worktree 的旧进程,或者 `api-server` 连到旧 SpacetimeDB 模块;此时 `/api/creation-entry/config` 会返回旧入口配置。 - 处理:先用 `Get-NetTCPConnection -State Listen -LocalPort 3000,8083,3103` 结合 `Get-CimInstance Win32_Process` 确认端口进程路径;停止串线的旧 `api-server`,再用当前 worktree 的 `npm run dev:spacetime -- --spacetime-port --database ` 和 `npm run dev:api-server -- --api-port --spacetime-port --database ` 拉起同一套服务。 - 验证:`GET /api/creation-entry/config` 应包含目标入口,且监听端口的命令行都指向同一个 worktree;浏览器创作 Tab 对应分类应显示入口卡。 -- 关联:`scripts/dev.mjs`、`.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联:`scripts/dev.mjs`、`.codex/skills/genarrative-dev-stack-port-routing/SKILL.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 ## Windows junction 工作区下 dev.mjs 直接执行入口要用 realpath 判断 @@ -3683,8 +3704,8 @@ - 现象:命令看似使用隔离 HOME / TMP、离线包管理器和不可达代理,仍能直接读取宿主用户文件、用原始 socket 联网,或由 `project.verify` 的平行 npm spawn 绕开 `command.exec` 限制。 - 原因:环境变量和 argv 白名单只约束主动配合的程序,进程组 / Job Object 主要解决生命周期;它们不建立 mount / network namespace,也不能保护 `.agent` Runtime 控制面。只包 `command.exec` 而漏掉 `command.start` 或 `project.verify` 同样属于 fail-open。 -- 处理:Linux 三个入口统一使用受信任系统 bubblewrap;项目根 rw,`.git / .agents / .codex / .hermes` ro,`.agent` 以 000 空 mount 隐藏,项目外普通用户路径不挂载,network namespace 默认隔离,嵌套 userns 禁用。全局 namespace canary 与项目 mount preflight 都必须在 revision / processId / 目标 program 前成功;任何失败都不回退宿主执行。Windows 在等价 restricted process / AppContainer 落地前继续标记为固定命令 legacy 边界。 -- 验证:不能只断言 bwrap argv。必须运行真实目标和子进程,分别检查工作区写入、宿主 sentinel、五个控制目录、原始 socket、PTY stdin / graceful terminate、Runner SIGKILL 后宿主 `/proc` 无项目 cwd 进程,以及 unavailable 时 marker 为零。 +- 处理:Linux 三个入口统一使用受信任系统 bubblewrap;项目根 rw,`.git / .agents / .codex` ro,`.agent` 以 000 空 mount 隐藏,项目外普通用户路径不挂载,network namespace 默认隔离,嵌套 userns 禁用。全局 namespace canary 与项目 mount preflight 都必须在 revision / processId / 目标 program 前成功;任何失败都不回退宿主执行。Windows 在等价 restricted process / AppContainer 落地前继续标记为固定命令 legacy 边界。 +- 验证:不能只断言 bwrap argv。必须运行真实目标和子进程,分别检查工作区写入、宿主 sentinel、四个控制目录、原始 socket、PTY stdin / graceful terminate、Runner SIGKILL 后宿主 `/proc` 无项目 cwd 进程,以及 unavailable 时 marker 为零。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs`、`command_exec.rs`、`process_session.rs`、`project.rs`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。 ## 工具链环境根不能把整个用户目录挂进命令沙箱 @@ -4232,7 +4253,7 @@ - 原因:两个进程角色共享同一 crate、target 和可执行文件,却被错误地当成两个互不相关的 dev service。更危险的是先启动 `GENARRATIVE_PROCESS_ROLE=all` 的 API:它会立即消费外部生成队列,可能在内部 BgFilter worker 尚未 ready 时领取任务。 - 处理:`npm run dev` 与 `npm run dev:api-server` 只创建一套 Rust watcher,并把两个进程作为组合重启单元:先停止 API 与 BgFilter worker,再只让 worker 的 `cargo run` 完成必要构建,等待 worker `/readyz`,最后启动并验活 API。交互 `rs api-server`、`rs bgfilter-worker` 在完整栈内也必须走同一组合重启。`ProcessRole::All` 永远不内嵌 BgFilter listener;父子进程共享解析后的内部 base URL / Token,Linux 第五端口固定为端口段 `start + 4`,Windows 把第五端口纳入统一探测和漂移。 - 验证:定向测试断言组合重启顺序为“stop API → stop worker → start/ready worker → start/ready API”,`dev:api-server` 自动带起同 runner worker,端口解析得到五个互不冲突的端口;再运行 `node --check scripts/dev.mjs`、dev-stack 定向测试和编码检查。 -- 关联:`scripts/dev.mjs`、`scripts/dev-stack-port-utils.mjs`、`.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +- 关联:`scripts/dev.mjs`、`scripts/dev-stack-port-utils.mjs`、`.codex/skills/genarrative-dev-stack-port-routing/SKILL.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## 固定 digest 不等于每个 CI job 都要强制拉镜像 @@ -4940,6 +4961,12 @@ - 部分旧包补充:rollback 的规范图/背景图必须保存旧字节与旧 manifest entry,不能把这两项缺失隐式当成空内容;显式 `regenerate` 因此只在这两项可信可回滚时开放。历史主图集、私有回执、公开清单或 canonical 切片可以缺失,但八个严格路径与受管顶层 asset identity 必须逐项冻结其真实 `Present/Some` 或 `Missing/None` 状态,补偿也必须恢复相同存在性。不要因为旧美术包缺切片而阻断重生成,也不要把本轮新建的严格文件误记成旧文件。 - 对话扫描与 claim 补充:历史中出现 `User A / User B / Assistant B` 时,B 已回答不代表 A 已回答,扫描必须继续寻找 A。成功 Direct 回复在 Rust 返回前已经落盘,前端冗余 append 失败不能据此重跑;普通错误回复的显式落盘失败时,恢复 claim 要保持到 React fallback writer 的同一 messageId append 明确收敛。writer 成功或明确失败后才释放;失败路径要停止该消息的自动迟到重试,再由显式 `/history` 复用原 stable turn。终态后及时删除 claim,避免 Set 无界增长。 +## GDD 历史审批回执误触发当前恢复提示(2026-08-27) + +- 现象:修改 GDD 后新版本标题和内容已正确落盘,但审批卡一直显示“审批状态正在恢复”。 +- 原因:`approval pending` 是当前 lineage 最新 GDD 的单例投影;恢复扫描却让每个历史 receipt 都拿它做 identity 比对。旧 receipt 与新 pending 不同并不表示损坏。 +- 处理:历史 receipt 只修复自身投影;只有最新 GDD 的 receipt 才能校验、更新或清理当前 approval pending。不要在前端隐藏 `recoveryPending`,也不要取消最新版本的 identity fail-closed 检查。 + ## Native shell CI 不能在测试阶段重新解析 Cargo registry(2026-08-26) - 现象:原生壳 job 的依赖预取成功后,AGC 检查仍在 `platform-llm` 测试阶段重新更新 registry index,并因 `symphonia` 下载的 TLS EOF 失败。 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index c7a2bccc2..4ee9e70b1 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -35,7 +35,7 @@ Genarrative / 陶泥儿当前主站聚焦图片画布创作、编辑器项目与 server-rs + Axum + SpacetimeDB ``` -SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.7.0` 对齐;官方发行包和容器镜像使用 `v2.7.0-hotfix3` 资产标签,二进制仍报告 `2.7.0`。 +SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐;CLI / standalone commit 固定核对为 `8e410d2842147bd8e5a32a9589cc00c19f7478e2`,官方发行包和容器镜像使用 `v2.8.3` 标签。 职责边界: diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index 1291c9fe1..349f14e18 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -4,7 +4,7 @@ - 3 名开发人员在各自环境使用本地 Agent,通过同一 Git 仓库同步代码和项目知识。 - 每个任务保持分支、工作树和修改范围清晰;多人或多 Agent 并行修改前先划分不重叠的文件边界。 -- 项目知识进入 `docs/` 与 `docs/project-memory/`;个人 `~/.hermes`、Agent 会话和本地配置不共享。 +- 项目知识进入 `docs/` 与 `docs/project-memory/`;个人 `~/.codex`、Agent 会话和本地配置不共享。 ## 开发前 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index a93160c97..14f0a4909 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -556,7 +556,7 @@ V1.11 把命令安全边界从“固定 program + argv 规则 + 隔离环境变 - `command.exec` 与 `command.start` 必须继续共用唯一 `ProjectCommandLaunchSpec`,`project.verify` 必须调用同一 sandbox launcher。真实 executable / argv 在该层包装为受信任系统 bubblewrap;一次性 Tokio child、PTY child wrapper、npm 验证脚本和全部后代不能有绕过该包装的生产 spawn 路径。 - bubblewrap 只允许从固定系统候选路径解析,文件必须是普通可执行文件且不能被当前普通用户写入。缺失、权限异常、namespace setup 失败或挂载失败都返回 `sandbox-unavailable` / preflight 失败;不得尝试裸 `unshare`、代理断网或无沙箱宿主执行作为 fallback。 - sandbox 使用独立 user / mount / pid / ipc / uts / cgroup / network namespace,禁用嵌套 user namespace,并启用 parent-death 收束。系统 executable / dynamic runtime 和明确工具链缓存只读挂载;规范项目根以原绝对路径读写挂载,cwd 仍必须是无 symlink / reparse point 的项目内目录。 -- 项目根挂载后覆盖控制目录:`.git / .agents / .codex / .hermes` 存在时按原路径只读挂载;`.agent` 使用不可读写的空 mount 覆盖,命令不能看到 Runtime sidecar、会话、审计或配置。项目内指向外部的 symlink 因目标未挂载而不可访问。 +- 项目根挂载后覆盖控制目录:`.git / .agents / .codex` 存在时按原路径只读挂载;`.agent` 使用不可读写的空 mount 覆盖,命令不能看到 Runtime sidecar、会话、审计或配置。项目内指向外部的 symlink 因目标未挂载而不可访问。 - HOME / USERPROFILE / TMP / Cargo / npm cache 使用 sandbox 内私有临时目录。允许只读复用不含凭据的工具链 source cache,但外部工具链环境根必须 canonicalize 后再次校验为与变量类型匹配的窄叶目录;`RUSTUP_HOME=$HOME`、`.rustup -> $HOME` 和其它宽用户目录必须失败关闭。不得挂载整个用户 HOME、AppData、SSH、云凭据、Cookie 或 Runtime 配置目录。 - 网络 namespace 默认无外部网络,HTTP(S) / ALL proxy 与离线包管理器变量只作为纵深防御。`networkAccess` 首版固定 `disabled`,模型输入不能开启;需要联网必须作为未来独立 approval escalation 设计,不能复用普通 confirm 偷渡。 @@ -572,7 +572,7 @@ V1.11 把命令安全边界从“固定 program + argv 规则 + 隔离环境变 - namespace canary 与项目 mount preflight 失败时,必须在 revision / processId 推进前返回稳定的 sandbox unavailable / setup 错误并保持项目命令未执行。当前 preflight 与随后真实 bwrap launch 是两次独立启动:真实 launch 若在目标 exec 前发生第二次 setup 失败,目标程序不会绕过沙箱执行,但尚无可信 exec-ready 握手证明失败阶段,revision 可能已经推进并按普通命令失败收束。这是 V1.11 已知残余,后续必须用 launcher 握手把“沙箱已建立且目标已 exec”与“仅准备采用沙箱”分开,不能把当前行为描述为原子保证。 - command log、terminal receipt、Agent DB 和 process record 至少记录固定 `sandboxMode=workspace-write / networkAccess=disabled / sandboxBackend=bubblewrap / sandboxProfileVersion=workspace-v1` 安全元数据,不记录 host mount source、用户 HOME、bwrap 完整 argv 或本地工具链路径。process record 使用 schema v2 持久化 launch 当时的四项元数据,poll / stdin / terminate 和旧 boot reconciliation 必须从 record / live session 读取,不能按当前平台静态猜测;旧 v1 record 只能迁移为 `legacy-unknown`。纯 preflight 失败记录 `unavailable / not-established`,不得谎报 bubblewrap 已建立。 -- 确定性真实进程测试必须证明:项目内构建 / 测试 / Git 读取成功;项目外普通文件读取与写入失败;`.git / .agent / .agents / .codex / .hermes` 写入失败;网络默认不可达;shell 子进程继承同一边界;bwrap 不可用时项目命令零执行且失败关闭。`command.start` 还必须让 PTY 后代实际执行 `setsid + chdir` 后的项目外读取、控制目录写入和原始 socket 负例,并断言 process record 与每段专用审计的四项 sandbox metadata。 +- 确定性真实进程测试必须证明:项目内构建 / 测试 / Git 读取成功;项目外普通文件读取与写入失败;`.git / .agent / .agents / .codex` 写入失败;网络默认不可达;shell 子进程继承同一边界;bwrap 不可用时项目命令零执行且失败关闭。`command.start` 还必须让 PTY 后代实际执行 `setsid + chdir` 后的项目外读取、控制目录写入和原始 socket 负例,并断言 process record 与每段专用审计的四项 sandbox metadata。 - deb / rpm 发布包声明 `bubblewrap` 宿主依赖;AppImage 不携带 bubblewrap sidecar,发布页和安装检查必须明确要求受支持版本的系统 `/usr/bin/bwrap` 或 `/bin/bwrap`。缺失时命令工具安全失败关闭,但该 AppImage 不算具备可用的通用开发能力。 - 真实 Provider disposable E2E 不给固定 program、文件名或工具顺序,要求模型自行发现项目技术栈,运行构建、测试和 Git 检查,并用结构化审计证明所有命令都在 workspace-write / network-disabled 下执行。上述门禁通过前不得宣称 V1.11 完成。 @@ -986,7 +986,7 @@ V1.24 修正仓库启动上下文把 `AGENTS.md` 与 README/CONTEXT 一律描述 V1.25 在 V1.24 仓库启动上下文上增加项目内 Skill catalog,但不把 Skill 正文预加载到每轮 prompt。目标是对齐 Codex 的 progressive disclosure:模型始终只看到用于触发判断的 `name / description / entryPath / contentSha256`,任务真实命中后再通过现有 `file.read` 获取 `SKILL.md` 正文,并只按正文导航读取必要 reference。Skill 是项目工作流知识,不是新工具、权限包或可执行插件。 -- 发现根固定为项目内 `.codex/skills//SKILL.md` 与兼容目录 `.agents/skills//SKILL.md`,只接受这两个根下的直接子目录入口,不递归把 reference 中的其它 `SKILL.md` 当独立 Skill。本仓库既有规范以 `.codex/skills` 为准;同名且两处都合法时 `.codex` 胜出,删除高优先级入口后 `.agents` 才可接管。V1.25 不扫描 AppData、用户主目录、全局 Codex/Hermes 安装目录、Git submodule 外部路径或网络 marketplace。 +- 发现根固定为项目内 `.codex/skills//SKILL.md` 与兼容目录 `.agents/skills//SKILL.md`,只接受这两个根下的直接子目录入口,不递归把 reference 中的其它 `SKILL.md` 当独立 Skill。本仓库既有规范以 `.codex/skills` 为准;同名且两处都合法时 `.codex` 胜出,删除高优先级入口后 `.agents` 才可接管。V1.25 不扫描 AppData、用户主目录、全局 Codex 安装目录、Git submodule 外部路径或网络 marketplace。 - `skill-name` 必须与目录名和 YAML frontmatter `name` 完全一致,使用 1-64 个 ASCII 小写字母、数字或单连字符,首尾必须是字母或数字;frontmatter 必须位于文件开头并提供非空字符串 `name / description`。YAML 使用结构化 parser;未知字段不产生 Runtime 能力。描述清洗凭据和绝对路径、折叠为单行并限制 2048 bytes。 - 单个 `SKILL.md` 最大 128 KiB,catalog 最多 64 项,prompt 中 Skill metadata section 最大 4 KiB。超限、解析失败、符号链接、路径不规范或读取失败的入口不进入 catalog;预算或读取导致的省略必须使 repository context 标记 `truncated=true`,不能把部分 YAML 当有效 metadata。 - 仓库启动上下文升级为 `repository-startup-context-v3`,新增有界 `skills` 列表。fingerprint 覆盖 active Skill 的规范入口路径、来源根、清洗后 name/description、清洗后完整文件 SHA-256 和截断状态;Skill 正文、metadata、优先级或入口增删发生变化时,任何受 repository context gate 保护的旧 pending action 都必须先形成 drift blocker,再在同一 run 重规划。shadowed 的低优先级同名入口不影响 active 语义。 @@ -1482,6 +1482,8 @@ npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-final-reply-transie V1.43 不放宽 V1.41 的文本型 `game-creator-provider-handoff.v1`,而是为 `requestKind=tool-plan` 增加独立私有账本 `.agent/runtime/tool-plan-handoffs//.json`,schema 固定为 `game-creator-tool-plan-handoff.v1`。同一 Agent/run 账本按 `(loopIteration, repairAttempt)` 单调保存已成功的 `repair-0..N` Provider 响应,每条绑定完整 retry identity、实际物理 `providerRequestId`、真实 request slot/attempt、Provider/model、去除 thinking 后的响应、thinking 归一化哈希/计数、完整 function call envelope、usage、响应指纹和创建时间。账本使用既有 `0600`、原子替换、父目录同步、`.previous` 恢复和写后完整回读;未知字段、乱序/缺口、重复 slot 冲突、超限、危险可执行路径、密钥或配置痕迹一律失败关闭。 +当成功响应因 handoff 校验失败而进入 `needs-reconciliation` 时,Runtime 额外在应用私有数据目录的 `diagnostics/provider-reconciliation//.json` 写入一次本地诊断。该诊断只服务人工排障,不参与恢复、重试或业务状态判断,可保留本次 Provider 响应、tool call arguments 和原始校验错误;项目 `.agent`、Agent DB、公共 event、CLI 与报告只保留安全摘要及该私有诊断的相对引用。诊断文件限制为 1 MiB,使用原子写入;应用配置目录不可用或诊断写入失败时,不改变既有 fail-closed reconciliation 语义。 + ### 提交、重放与所有权 - 每个 tool-plan 物理请求的顺序固定为:Provider 成功 -> tool-plan handoff 追加并回读 -> 同一实际 requestId lifecycle `completed` -> 解析/格式修复或动作预检。function arguments 只存在于私有 handoff 与后续 pending/action batch。protocol/repair 公共审计共同保存 `agentId/taskId/sessionId/runId/source/loopIteration/repairAttempt/requestSlot/responseFingerprint/providerRequestIdSha256/protocol`;protocol 只额外保存 `functionCallCount/callIdSha256s/functionNames/responseIdSha256/responseIdChars` 和既有 normalization 字段,其中 function names 必须由 catalog 绑定;repair 只额外保存 attempt/maxAttempts、协议错误/preview 哈希与字符数及 `callIdSha256/functionNameSha256`。公共 task、event、Agent DB、CLI 和报告不得保存原始 callId/callIds/responseId/providerRequestId。两类审计都在 Agent DB append 锁内按完整 Agent/task/Session/run/source/slot 身份做全历史 compare-and-append,不能以受限尾部读取替代幂等。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 47323db92..4cf38ccd8 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -216,6 +216,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - 进程与节点:External Runner 按“有效 Agent LLM 凭据/Responses 路由 + `projectId/agentId/sessionId/runId`”隔离长期 `codex app-server --stdio`,即每个权威节点 run 直接持有自己的 Codex CLI 子进程与 ephemeral thread,每次完整权威请求映射 turn。同一节点 turn 串行,节点之间进程级隔离;单节点连接失败不得使其它节点同时失去终态。Codex thread 不写 durable recovery;节点完成、重启、retry、handoff 和 finalization 仍只认 AGC 账本。 - LLM 配置:`apiKind` 始终只接受 `openai_responses`;非空 Key 转换为 app-server model provider,base URL 生效,Key 仅走专用环境变量;空 Key 只桥接用户 Codex `auth.json`,不继承环境 `CODEX_API_KEY`。设置面板在 app-server 模式继续显示并保存 model、effort、stream、全局/逐 Agent Key 与路由配置;`openai_chat / anthropic` 明确提示切 `provider`,不得悄悄忽略。`stream=true` 接入 app-server 文本 delta;`webSearchEnabled=true` 只允许 DirectProject 经客户端审核的 `agc_web_search` 使用,不得启用 Codex 原生 webSearch 或任意网络。 - 安全与取消:临时 cwd、隔离 `CODEX_HOME` 与 OS HOME、read-only、network off、never approval,并在启动前关闭 web/multi-agent/shell/browser/plugin/image 等原生能力;取消从 turn-start pending 阶段就跟踪且只 interrupt 当前 turn。已发送 turn 后连接断开或终态丢失进入 reconciliation,只关闭当前节点进程且不重放同一 request slot;明确 failed/interrupted 不按 transport 重试。 +- remote-control 认证边界:没有 ChatGPT `auth.json` 的 API Key / provider-proxy app-server 在启动时设置 Codex 内部环境变量 `CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED=1`,让 remote-control 以 `desired_state=Disabled` 启动,避免上游进入 1Hz 认证重试;不再依赖需要 ChatGPT 登录态的 `remoteControl/disable` RPC。只有实际桥接 ChatGPT 登录态的 AuthBridge 保持 remote-control 可用。API Key 子进程同时使用 `RUST_LOG=warn` 收敛剩余预期噪音,不伪造 `auth.json` 或静默继续。 - 资源与退出:app-server pool 按实际凭据快照/base URL/API kind/CLI 版本和节点 run 身份隔离并做有界 LRU;空 AppData Key 必须读取同一份有界 `auth.json` 字节来生成池指纹并桥接隔离登录态,继承的 `CODEX_API_KEY` 始终移除,node thread 也只淘汰 inactive LRU。Runner 正常、强制和 watchdog 退出都显式关池,Linux child 绑定 parent-death signal,防止强杀 Runner 后遗留带凭据孤儿进程。stdout NDJSON 与 stderr 无换行记录均有硬上限;stderr 原文不写入诊断,只记录固定分类、总字节数、SHA-256 和可取得的退出状态。 - 旧配置迁移:既有 AppData 若没有 `agentMode`,只有全局和逐 Agent 路由均为 `openai_responses` 时迁移到 `codex_app_server`;存在 `openai_chat / anthropic` 时显式保留 `provider`,避免打开项目自动恢复时把所有节点批量写成 `invalid-config`。用户确认端点支持 Responses 后,可在设置中显式切换并保留原 model/base URL/API Key。 - 验收:fake JSON-RPC fixture、三态 UI/config、配置指纹、unknown-terminal 零重放、旧两种模式回归和显式 ignored 真实 smoke 全部通过后,才可视为模式切换完成。 @@ -227,7 +228,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - 状态询问、解释和不冲突补充默认继续当前 Provider;明确停止、改向或会让在途方案过期时才允许请求中断。判定失败时继续任务,并在下一安全边界消费消息,不得把失败等同于需要中断。 - UI 通过持久 conversation 展示关联回复,刷新后仍可见;返回的 `assistantReply / interruptDecision / decisionReason` 只用于本次命令结果与诊断,不形成第二个会话或第二个 Run。 -V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .codex / .hermes`;其中 `.agent` 对项目命令隐藏,其余控制目录只读。 +V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .codex`;其中 `.agent` 对项目命令隐藏,其余控制目录只读。 2026-08-04 起,Runtime 的公共工具规划指令、Supervisor 协作编排 playbook、条件 overlay 和编译期静态 Agent 节点目录统一由版本化 Prompt Bundle 驱动,位于 `apps/ai-game-creator-shell/src-tauri/prompts/runtime/`。`manifest.json` 是 section 路径、组合顺序、平台 / Editor 变体、role overlay、Provider 协作 fragment,以及 Supervisor 与六组专业 Agent 静态目录的单一来源;role overlay 只允许 `rootSourceKind` 强类型语义 selector,构建期拒绝未知 kind,运行期把权威 source 常量映射为生成 kind。`build.rs` 同时监听 Bundle 每一级目录、manifest 和已登记 section,保证任意嵌套目录新增孤立 Markdown 都会触发增量构建,并以失败关闭方式校验 schema、引用、路径 / symlink、孤立 Markdown、selector、节点身份、旧 alias 和生成标识符,再生成 `'static + Copy` Rust 定义并编译进发布二进制。公共 runtime system header 保持身份中立;生成的 Supervisor planning composition 必须复用 `supervisorChat.identity`。每个 section 只能属于 runtime composition、Supervisor composition、chat 字段、platform variant、visual variant、role overlay 或 Provider fragment 中一个语义所有者;唯一例外是同一 identity section 由 Supervisor planning 与 `supervisorChat.identity` 显式复用,从而同时阻断 Supervisor 指令外泄和动态 variant 与静态 composition 的重复注入。专业节点 taskId / group / role 还必须在构建期与 `shared-contracts::new_game_creation_app_seed_tasks()` 强一致,防止身份合同、静态目录和正式 seed DAG 漂移。Bundle 承载公共指令、隔离 Agent 合同、平台差异、角色选择、并行委派、all-join、视觉返工、claim gate,Supervisor 共享核心身份、interaction / final-reply 专属合同及其组合,以及首批协作、delivery 收敛、manifest wait、试玩后续委派等 repair 自然语言合同;background planning 在 system composition 复用核心身份,所有 user context 都不再重复注入 Supervisor 身份合同正文。`agent_runtime_native_executable_tools()` 仍是原生可执行工具的权威源列表,同时供 Prompt 工具目录与 native capability registry 使用,MCP 工具只从当前请求的动态 catalog 暴露。最终 Provider 请求必须通过生成的 section、composition、overlay 与 provider fragment API 构建,禁止恢复直接 `include_str!("prompts/runtime/...")`、在 Provider 或 `prompt.rs` 源码中复制协作 graph 文案,或依赖自然语言精确 `.replace()` 注入工具合同、平台规则或角色规则。Bundle 不是完整可执行 graph:正式 DAG 依赖边、权限、沙箱、委派容量、持久 all-join 状态机、完成门和身份校验仍由 Rust、`shared-contracts` 与经校验的 `.agent/collaboration-policy.json` 强制执行,不允许通过 Skill、外部配置或任意运行时 Prompt 覆盖绕过。 @@ -245,7 +246,7 @@ Prompt 静态门禁必须断言上述 Bundle section 当前定义的权威语义 2026-07-14 V1.10 真实 `gpt-5.5` 验收:`process-session` 在无工具配方任务中完成 start / 3 次连续 cursor poll / stdin / terminate,41 条 task、75 条 event、63 条 Agent DB、8 条 receipt、4 套确认生命周期和唯一 completed / assistant,fixture launch 为 1,终态 PID / 端口、重放、重复与公共正文 / 密钥 / 诱饵泄漏均为 0。独立 Runner 强杀套件形成 21 条 task、34 条 event、36 条 Agent DB,新 boot 保持原 run / session,只产生 1 条 reconciliation,launch 仍为 1、PID reconnect / final / assistant / 重放 / 泄漏均为 0;两个 disposable 项目均已清理。 -2026-07-14 起,同一文档的“V1.11 OS 强制工作区沙箱与通用项目命令”替代 V1.2 / V1.10 在 Linux 上的固定 program / 严格 argv 白名单边界。`command.exec / command.start` 继续接受结构化 `program + args + cwd`、沿用 confirm policy、durable action、revision、verification、输出和进程会话协议;`project.verify` 也必须复用同一 launcher,不能保留平行的宿主 npm spawn。Linux 只在受信任系统 bubblewrap 创建的 workspace-write sandbox 内启动真实命令:项目根可写,`.git / .agents / .codex / .hermes` 只读,`.agent` 不可见且不可写,项目外普通用户文件不挂载,网络 namespace 默认隔离,所有后代继承相同边界。program 只接受无路径分隔符的裸可执行名并从受信任 PATH 解析,argv 只保留数量、长度和控制字符硬限制;允许 `bash -lc`、Git、构建器、测试器和项目脚本在沙箱内自行工作。外部工具链环境根必须 canonicalize 后校验为窄工具链目录,禁止把整个 HOME 或其符号链接目标挂入沙箱。bubblewrap 缺失、不可执行或 setup 失败必须在项目命令执行前失败关闭,不允许退回宿主全权限。process record v2 与命令审计持久化真实 launch metadata,失败不能按平台静态冒充已建立沙箱。共享 `os-workspace-sandbox` capability 只标记 Linux;Windows 首版继续使用原固定白名单、隔离环境和 Job Object,不能宣称已达到同等 OS sandbox。deb / rpm 声明 bubblewrap 依赖,AppImage 依赖宿主预装且缺失时功能失败关闭;approval 与 sandbox 仍是两层独立门禁。 +2026-07-14 起,同一文档的“V1.11 OS 强制工作区沙箱与通用项目命令”替代 V1.2 / V1.10 在 Linux 上的固定 program / 严格 argv 白名单边界。`command.exec / command.start` 继续接受结构化 `program + args + cwd`、沿用 confirm policy、durable action、revision、verification、输出和进程会话协议;`project.verify` 也必须复用同一 launcher,不能保留平行的宿主 npm spawn。Linux 只在受信任系统 bubblewrap 创建的 workspace-write sandbox 内启动真实命令:项目根可写,`.git / .agents / .codex` 只读,`.agent` 不可见且不可写,项目外普通用户文件不挂载,网络 namespace 默认隔离,所有后代继承相同边界。program 只接受无路径分隔符的裸可执行名并从受信任 PATH 解析,argv 只保留数量、长度和控制字符硬限制;允许 `bash -lc`、Git、构建器、测试器和项目脚本在沙箱内自行工作。外部工具链环境根必须 canonicalize 后校验为窄工具链目录,禁止把整个 HOME 或其符号链接目标挂入沙箱。bubblewrap 缺失、不可执行或 setup 失败必须在项目命令执行前失败关闭,不允许退回宿主全权限。process record v2 与命令审计持久化真实 launch metadata,失败不能按平台静态冒充已建立沙箱。共享 `os-workspace-sandbox` capability 只标记 Linux;Windows 首版继续使用原固定白名单、隔离环境和 Job Object,不能宣称已达到同等 OS sandbox。deb / rpm 声明 bubblewrap 依赖,AppImage 依赖宿主预装且缺失时功能失败关闭;approval 与 sandbox 仍是两层独立门禁。 2026-07-14 V1.11.1 第一切片:`command.exec / project.verify` 已共用受信任 trampoline launcher。bwrap 的 `child-pid` 只推进 child-created,`--block-fd` 放行后仍须收到 `SANDBOX_READY`;Runtime 完成 revision / verification durable callback 后才发送 `COMMIT_EXEC`,收到 `EXEC_ESTABLISHED` 后才计算业务 timeout。当前不把这套 stdin 私有控制通道用于 PTY;`command.start` 与 process record v3 仍是下一切片,相关链路完成前 V1.11.1 保持进行中。 @@ -1183,7 +1184,7 @@ game-project/ ## 2026-08-20 Direct Codex 审核 Skill Pack 与受控工具内核 -- 普通项目对话只由一个 project-bound Codex app-server thread 执行。客户端系统提示词只放最小工程合同、当前游戏源码有界快照、项目 prompts 和审核 Skill 索引;不再批量读取项目 `.codex/.agents/.hermes` Skill 正文,也不恢复 Supervisor、专业 Agent 或 harness。 +- 普通项目对话只由一个 project-bound Codex app-server thread 执行。客户端系统提示词只放最小工程合同、当前游戏源码有界快照、项目 prompts 和审核 Skill 索引;不再批量读取项目 `.codex/.agents` Skill 正文,也不恢复 Supervisor、专业 Agent 或 harness。 - 首页恢复“做游戏 / 做素材 / 做方案”三个创作类型,默认“做游戏”。该选择与设置页的 Agent Runtime 模式无关;每次首页提交仍只自动创建一个新项目并进入项目工作台。用户正文原样进入项目对话,`game|art|doc` 仅作为受限结构化首轮上下文传给同一 Codex thread,不拼接“初始意图”文案、不产生首页对话、不切换 Provider 或恢复旧 Runtime 编排。 - `agc-skill-pack.v1` 只包含项目结构、陶泥儿美术、Web 游戏实现、真实浏览器试玩、客户端资源投影五项 Skill。清单记录用途、触发条件、所需工具、版本和内容 SHA-256;审核文本按 UTF-8 读取并将 CRLF 规范为 LF 后计算指纹和安装,避免混合换行造成 Windows / Linux 构建结果漂移,语义内容变化时必须同步重算对应清单指纹并提升版本。同步统一运行 `npm run agc:skill-pack:sync`,只读校验由 AGC `typecheck` 和 release build 自动执行,发现漂移时直接列出 Skill 与实际摘要,不让失配内容进入构建产物。客户端把审核文件安装到隔离目录后通过 app-server `skills/extraRoots/set + skills/list` 注册并复核,完整正文由 Codex 原生 Skill 机制按意图加载,一层引用只能经 `agc_read_skill_resource` 读取清单内 Markdown。引用路径按平台无关规则拒绝反斜杠、盘符、UNC、绝对路径和 `..`,不能依赖当前宿主的 `std::path` 语义判断其它平台路径。 - DirectProject 只连接客户端内置的 `agc_tools` STDIO MCP,工具固定为审核引用读取、标准陶泥儿美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive 语义生成、已登记图片去背景和 desktop/mobile 浏览器试玩。MCP 进程只做协议;真实浏览器与付费 External v1 调用通过随机 loopback 地址回到客户端主进程,因此不复制 GUI 登录态、开发者 Key、项目路径、revision、operation 或幂等键到模型上下文。已登记工具固定自动批准,但付费资源工具仍由客户端绑定稳定回合身份、限制单回合请求数、串行执行并优先恢复匹配账本;通用 shell、Codex 原生 webSearch、任意网络、多 Agent、插件和外部 MCP 继续关闭。`codex_app_server` 模式要求 `llm.webSearchEnabled=false`。 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index 4d4fd681f..b48a05c30 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -173,7 +173,7 @@ D9/D10 描述的「manifest ready-task 调度器在 Supervisor 下游启动策 | hydrate read-model schema | `plan-gdd-state-view.v1` | | GDD 状态 | `draft \| ready_for_approval \| revision_requested \| approved \| rejected \| superseded` | | 单项决定状态 | `confirmed \| default_pending \| prototype_pending` | -| 回答来源 | `user_option \| user_freeform \| default` | +| 回答来源 | `user_option \| user_freeform \| user_revision \| default` | | 审计 recordType | `agent.runtime.plan.gdd_decided` | | planning typed 指纹文本 | `sha256-serde-json-v2:<64 位小写十六进制>` | | 现役 action/profile binding digest | `<64 位小写十六进制>`,无前缀 | @@ -402,7 +402,7 @@ Runtime 注入并强校验以下精确结构: - **轮次计数与上限**:本轮是第几轮由委派链上的 `clarification_round` 派生值决定(沿 `repair_of_delegation_id` 上溯推断,见第 23.5 节),上限 3;不再由 session 自行累加 `roundsUsed`。session 仍是 decisions 数组与 GDD 草稿内容的权威,但**不再是轮次状态机的权威**。 > **随之而来的合同影响 —— 2026-08-13 已全部收口。** 第 3 节注册表的 `plan-decision-checkpoint.v1` 与 request kind、第 8.6 节 `plan-session.v1` 的 `activeQuestion` / `roundsUsed` / `supersededCheckpointHandoffs`、第 9 节的 checkpoint domain 与 `supersededCheckpointProviderRequestIds`、第 12 节的 checkpoint stale 状态机、第 14 节与 activeQuestion 相关的恢复行,均已随本节重写一并删除或改写;第 9.1 节 golden vector 已按新 identity 重新生成(3857 bytes,`a59856de7e…`)。 -- 用户明确输入优先于 Agent 默认;默认建议必须标为 `default_pending`,手感、节奏、镜头、可读性或重玩差异等需要验证的结论标为 `prototype_pending`。 +- 用户明确输入优先于 Agent 默认;默认建议必须标为 `default_pending`,手感、节奏、镜头、可读性或重玩差异等需要验证的结论标为 `prototype_pending`。审批阶段的用户修改意见使用 `answerSource=user_revision`、`round=0`;Runtime 仅在 session 带 revise/reject 的 `lastDecisionRef` 时接受该来源。 - session revision 1 由 Runtime 先写入固定 `initial-request` 决定:topic=`初始需求`、state=`confirmed`、answerSource=`user_freeform`、round=0、answerSummary 精确等于规范化后的 1~400 scalar 初始用户需求。Provider 不能改写或省略这条来源记录;超过上限的初始输入先要求用户收束,不能截断。 ### 5.2 决策卡 @@ -663,7 +663,7 @@ Provider 只能提交设计内容,不能提交或覆盖任何 Runtime 身份 该 input 及所有嵌套类型都使用 `deny_unknown_fields`;`game.platformFacts`、任意 `basis`、`projectId/gddId/version/submissionId/approvalRequestId`、action/run/session identity、时间与任何 fingerprint 一旦出现在 Provider input 中即返回 `PLAN_INVALID_REQUEST`。Runtime 在发出本轮 Provider request 前把当前 `sessionRevision/sessionFingerprint` 绑定进内部执行上下文,在项目锁内验证该 CAS 后,才把 project、GDD、版本、durable action、source/profile、session/run、时间、固定 `platformFacts`、全部 `basis:null` 与 fingerprint 注入 `plan-gdd.v1`。字段数量和文本限制按第 8.3 节对应 durable 字段执行。 -input 中必须逐项包含并精确等于 source session 的全部 `decisionsSummary` 和 `prototypeValidationItems`,不得改变决定的 id/topic/state/answerSource/round/answerSummary,也不得改变原型项正文或顺序;每个已提问决定因此具有可验证的 1~3 轮来源。额外 decision 只允许是未提问默认:`default_pending + default + round=0`,且不能为它伪造 prototype item。唯一允许的 `confirmed + user_freeform + round=0` 是 Runtime 创建的固定 `initial-request`,其 answerSummary 精确等于初始用户需求。直接出稿因此可以合法使用 `roundsUsed=0`,但仍至少提交该初始 decision。 +input 是当前 GDD 的完整快照,不要求与 source session 的 `decisionsSummary` 和 `prototypeValidationItems` 逐项相等。审批修订可用 `user_revision + round=0` 修改、删除或新增决定;未涉及内容由 Agent 以当前 GDD 为基线保持不变。Runtime 仍校验决定结构、原型项双射、`initial-request` 首项、身份和 CAS。payload 出现 `answerSource=user_revision` 时,当前 session 的 `lastDecisionRef.action` 必须是 `revise` 或 `reject`;首次提交、澄清续跑和没有待处理用户修订的普通质量返工返回 `PLAN_INVALID_REQUEST`。用户修订周期内的质量返工可以继续携带 `user_revision`,但其 planning child 必须直接继承当前 session 的 `latestDelegationId`,不得从旧 delivery 另起分支。该闸只作用于新版本 create,同 `submissionId` replay 不重判。唯一固定的 `confirmed + user_freeform + round=0` 是 Runtime 创建的 `initial-request`。 ### 8.3 `plan-gdd.v1` @@ -1152,15 +1152,7 @@ GDD handler 只能从已验证 batch binding 复制 `sourceSessionRevision/sourc 4. Provider transient failure/物理中断但 session、context 和 request slot 未变时,才沿用同一 base ID 的 attempt 派生规则。已知 retryable transport/upstream failure 先把旧 attempt durable 闭合为 `failed`;Runner/进程恢复只有在 boot/owner/lease 证据证明旧物理请求不再存活且无 handoff/batch 时,才闭合为 `interrupted`。旧终态写入、同步并回读成功后,才能创建 attempt N+1 的新 `started`;不能原地复用同一 providerRequestId,也不能让两个 started attempt 并存。无法证明旧请求已终止时进入 recovery required,不自动重发。每个 attempt 始终有独立 `started → completed|failed|interrupted` lifecycle。 5. 除第 1~2 项明确允许的同 binding `started + ready batch` 崩溃组合,以及上文 delivery 问题落盘/答案绑定的已消费证明外,binding 缺失/损坏、lifecycle 与 batch 不一致、同 revision 下 requestContextFingerprint 漂移、session 不是合法 successor,或 batch 已进入执行/等待状态时返回 `PLAN_NEEDS_RECONCILIATION`。此路径不自动删除、不补默认 binding、不重绑、不重试。 -严格 submit input 被 Runtime 以 `PLAN_INVALID_REQUEST`、`PLAN_SESSION_DECISIONS_MISMATCH`(2026-08-21 补,见下)或由该输入导出的候选 GDD `PLAN_SIZE_LIMIT` 拒绝时,当前策划子 run 最多产生 **5 次** `plan.submit_gdd / rejected` observation:前 4 次关闭原 sole-action batch 后可在同一 run 续跑,让 Provider 根据最后一条 observation 修正;第 5 次仍须先完整落盘 rejected observation,再把该 run 终态失败,**不得**请求第 6 次 Provider tool-plan。该分类只针对本次 Provider input / 候选 GDD;读取既有不可变 GDD 或 receipt 时出现同名大小上限、既有 lineage 已达版本上限,或任何其它 durable authority 异常,一律是 `PLAN_NEEDS_RECONCILIATION`,不得消耗 Provider 重试额度。计数是 Runtime state 的 durable、每个 child run 独立的字段,进程重启不能清零;只有新建的策划 child run 才从 0 开始。它不依赖前端、Prompt 文字或 Provider 自报,且普通工具 observation 不计入。 - -**(2026-08-21)台账逐项比对失败从 `PLAN_SESSION_CAS_CONFLICT` 拆出为 `PLAN_SESSION_DECISIONS_MISMATCH`,并纳入上述可重试分类。** 第 8.2 节「input 必须逐项包含并精确等于 source session 的 `decisionsSummary` 与 `prototypeValidationItems`」这条校验(实现为 `planning_submit.rs` 的 `session_decisions_match_input`)原先与三条真 CAS 判据(`sessionRevision` 溢出、session 已被其它动作推进、Runtime source revision/fingerprint 无效)共用一个错误码,因此被 `plan_submit_error_is_business_rejection` 漏掉,一次不匹配即 `needs-reconciliation` 硬阻断整个策划子 Agent。 - -两者性质本就不同,按本节自己的判据即可区分:真 CAS 说明 **durable 权威**已变或已坏,重交同一份 input 不可能成功;台账不匹配时权威完好,错的是**本次 Provider input**——策划子 Agent 把决策摘要抄漏、抄错,或多追加了一条非 `default_pending` 决定。后者正是本节划归「本次 Provider input / 候选 GDD」的那一类。 - -**不变量未放松**:不匹配照样拒绝、照样不产生任何事实,只是拒绝的后果从「叫人核对」变成「回灌 rejected observation 让 Provider 改稿」,仍受同一个 5 次 durable 预算约束,第 5 次照常终态失败。伪造用户确认(追加 `confirmed + user_option`)等第 8.2 节禁止的写法一条都没有变得可行。 - -**触发这次拆分的实测**:策划子 Agent 连续三次 submit 撞形状层(`PLAN_INVALID_REQUEST`),每次都按回灌的理由改对一部分——机制运转正常;第四次形状终于合法,随即撞上台账比对这一支,直接 `needs-reconciliation`,整条链路零产物收场。即**越接近提交成功越容易撞上不给重试的门**,这与「5 次预算让 Provider 自行收敛」的设计意图直接冲突。 +严格 submit input 被 Runtime 以 `PLAN_INVALID_REQUEST` 或由该输入导出的候选 GDD `PLAN_SIZE_LIMIT` 拒绝时,当前策划子 run 最多产生 **5 次** `plan.submit_gdd / rejected` observation:前 4 次关闭原 sole-action batch 后可在同一 run 续跑,让 Provider 根据最后一条 observation 修正;第 5 次仍须先完整落盘 rejected observation,再把该 run 终态失败,**不得**请求第 6 次 Provider tool-plan。该分类只针对本次 Provider input / 候选 GDD;读取既有不可变 GDD 或 receipt 时出现同名大小上限、既有 lineage 已达版本上限,或任何其它 durable authority 异常,一律是 `PLAN_NEEDS_RECONCILIATION`,不得消耗 Provider 重试额度。计数是 Runtime state 的 durable、每个 child run 独立的字段,进程重启不能清零;只有新建的策划 child run 才从 0 开始。它不依赖前端、Prompt 文字或 Provider 自报,且普通工具 observation 不计入。 第 2 项的自动前滚必须与 session successor、batch supersede/cleanup 和 replacement request 的 started 写入都在项目锁内按幂等步骤恢复;任一断点重启后只能继续相同步骤。这样合法 steer 能确定性替换旧输出,而身份污染不会被“自动恢复”掩盖。 @@ -1169,7 +1161,7 @@ GDD handler 只能从已验证 batch binding 复制 `sourceSessionRevision/sourc main loop 不能把 submit 当成普通 action dispatch:在 durable action identity 建立后、生成普通 command ID 或进入 action executor 前,必须进入 `plan.submit_gdd` 专用分支。该分支重验 exact plan identity,执行下列提交与投影。**(2026-08-14 按 M1B-2 实现边界收口)** 本包只负责校验、定版、写不可变 GDD、重建 index、渲染 `game/fast_gdd.md`、安装 session successor 并终止策划子 run;**不创建 `.agent/planning/pending.json` / `gdd-approval` planning pending,不创建审批卡,也不把 Supervisor 或策划子 run 投影为审批等待**。`gdd-approval` pending 与 Supervisor 等待态属于 `M1C-1`,还要受第 13.0 节 `M1C-2a` 验收取证门约束。原 submit 在进入专用分支前已经建立的 generic `game-creator-pending-action.v5` standalone pending 与 `game-creator-provider-action-batch.v4` action batch 必须原样保留,作为后续 receipt/terminal observation 的同 action 恢复锚点;GDD create 成功不等于该 action 已 observed。 1. 解析第 8.2 节 strict input;在项目锁内重读 project identity、策划子 run 与委派根身份、Provider request 所绑定的 session CAS、canonical GDD 链及原 submit 的 generic v5 standalone pending / v4 batch anchors。不信任 Provider payload 中不存在也不允许出现的版本、时间、平台事实或身份;M1B-2 不读取或创建尚未实现的 approval receipt / planning pending。 -2. 验证文本上限、轮次、决定状态和 prototype item 一一对应;`decisions` 必须先逐项等于 source session 的完整决定前缀,前缀之后只允许追加 `state=default_pending + answerSource=default + round=0` 的未提问默认决定,任何伪造为用户已确认的额外决定都按 session CAS 冲突拒绝。Runtime 注入固定 platformFacts 和所有 `basis:null`,以当前 durable actionId/裸 action fingerprint 作为 submission identity。 +2. 验证文本上限、轮次、决定状态和 prototype item 一一对应;`decisions` 按本次完整 GDD 快照校验,不与旧 session 内容逐项比较。`round=0` 的非首项决定只能是 `answerSource=default`(默认建议)或 `answerSource=user_revision`(审批修改),分别对应允许的状态集合。`user_revision` 还要求当前 session 已有 `lastDecisionRef.action ∈ {revise, reject}`;没有该引用时不得把未确认项标成审批修改。已有 session 创建新的 planning child 时,`repairOfDelegationId` 必须精确等于旧 session 的 `latestDelegationId`;这条 continuation 游标约束在 Provider 请求前生效,防止旧 delivery 重新成为当前分支。Runtime 注入固定 platformFacts 和所有 `basis:null`,以当前 durable actionId/裸 action fingerprint 作为 submission identity。 3. M1B-2 尚无 receipt writer:只要已有任一 GDD,新的不同 submissionId 就返回 `PLAN_PENDING_GDD_EXISTS`;同 submissionId 只允许按历史 binding replay。`M1C-1` 接入有效 approve/revise/reject receipt 后,才把边界扩为“最新版本已有 receipt 才允许下一版本”。 4. 当前 M1B-2 的首次版本固定为 1;未来版本仍只能取最后一个连续有效版本加一,范围 1~128,不允许缺号或扫描任意文件补号。 5. 新提交由 Runtime 生成并冻结 `approvalRequestId/createdAtUtc`,填充全部 durable identity、source session binding 和时间,计算 GDD fingerprint,以第 10.1 节算法 create-only 发布 `gdd.v{N}.json`。同 submissionId replay 必须先找到并严格读取既有 GDD,复用其中 Runtime 生成的版本、request/time 与 identity 后再比较,不能用新时间制造假冲突。 @@ -1449,7 +1441,6 @@ type PlanGddError = { | 'PLAN_UNSUPPORTED_KNOWLEDGE_BASIS' | 'PLAN_CORRUPT_AUTHORITY' | 'PLAN_SESSION_CAS_CONFLICT' - | 'PLAN_SESSION_DECISIONS_MISMATCH' | 'PLAN_SESSION_RECOVERY_REQUIRED' | 'PLAN_NEEDS_RECONCILIATION' | 'PLAN_DURABILITY_FAILED' diff --git a/docs/technical/【技术说明】AGC接第三方Provider的兼容性缺陷-2026-08-19.md b/docs/technical/【技术说明】AGC接第三方Provider的兼容性缺陷-2026-08-19.md index b7bf9ae78..97adc4d00 100644 --- a/docs/technical/【技术说明】AGC接第三方Provider的兼容性缺陷-2026-08-19.md +++ b/docs/technical/【技术说明】AGC接第三方Provider的兼容性缺陷-2026-08-19.md @@ -1,8 +1,8 @@ # 【技术说明】AGC 接第三方 Provider 的兼容性缺陷 - 首次记录:2026-08-19 -- 最新核对:2026-08-25,当前实现仍保留本文所述 Provider 分发约束 -- 结论:**这不是单一策划链路的问题**。各创作流程共用同一套 Provider 分发;第三方端点必须满足当前 `agentMode`、`apiKind` 和工具调用协议约束。缺陷 4 已修复,其余限制仍按本文处理。 +- 最新核对:2026-08-27,当前实现仍保留本文所述 Provider 分发约束 +- 结论:**这不是单一策划链路的问题**。各创作流程共用同一套 Provider 分发;第三方端点必须满足当前 `agentMode`、`apiKind` 和工具调用协议约束。缺陷 4 已修复;`llm.stream=true` 时 Provider tool-plan 现在按配置发送流式请求并在后端聚合完整响应,前端展示合同不变。其余限制仍按本文处理。 --- @@ -14,6 +14,7 @@ | 2 | `codex_app_server` 模式把第三方端点喂给 codex | apiKind≠openai_responses 时秒挂;否则 413 + 工具误用,180 秒超时后留下待核对的孤儿请求 | 模式前提未被约束 | | 3 | `provider` 模式下 `tool_choice=required` 与 DeepSeek 思考模式互斥 | 首个 tool-plan 请求 400,整个 runtime 起不来 | 参数空间缺一个值 | | 4 | 普通 action 批次带 plan update 时,两条预检规则互斥 | 「更新计划 + 委派专业 Agent」同一轮返回就报「批次成员身份或顺序不匹配」 | **本分支回归**(已修) | +| 5 | `llm.stream` 只记录配置,不驱动 Provider tool-plan 传输 | 要求 `stream=true` 的网关第一发 tool-plan 得到 HTTP 400,整轮不可用 | 传输配置失效(已修) | 缺陷 1~3 叠加的结果:**当前代码里没有任何一组配置能让 DeepSeek 跑起来**。缺陷 4 与 provider 无关,换成 `gpt-5.6-terra` 打通 LLM 链路后才暴露出来。 @@ -291,3 +292,26 @@ let expected_member_plan_update = batch - DeepSeek 网关 413 的具体阈值,以及 `provider` 模式下 AGC 自组的请求体是否也会触顶。 --- + +## 9. 缺陷 5:`llm.stream` 未作用于 Provider tool-plan(已修) + +### 现象 + +`agentMode=provider`、`llm.stream=true` 时,审计与重试指纹记录 `stream=true`,但首个 tool-plan 仍调用 `LlmClient::run()`,请求体实际为 `stream=false`。只接受流式请求的 OpenAI 兼容网关返回 HTTP 400 `Stream must be set to true`;由于这是本地请求构造错误,重试同一请求无法恢复。 + +### 修复边界 + +Provider 的持久化重试分发与常规重试分发统一按 `llm.stream` 选择 `stream_run()` / `run()`。`stream_run()` 负责聚合文本、工具调用与终态,tool-plan 仍在响应完整后按现有协议解析、校验和交接;不把半截 tool-call 参数发布给前端,也不改变最终回复的 response-stream 合同。 + +### 回归 + +- `response_stream_uses_distinct_streamed_final_reply_for_responses_and_chat`:覆盖 Responses / Chat 两种 wire 的 tool-plan 与 final-reply 请求均发送 `stream=true`。 +- `response_stream_disabled_keeps_direct_planning_reply_to_one_request`:覆盖 `llm.stream=false` 时 tool-plan 仍发送 `stream=false` 且保持单请求直接收束。 +- `background_agent_runtime_executes_streamed_native_function_tool_plan`:覆盖 Anthropic tool-use 分片在 Shell Runtime 中聚合为原生工具动作,并完成 tool-plan 协议审计与动作执行。 +- `platform-llm` 既有 Chat / Responses 流式工具调用聚合用例继续覆盖分片工具参数装配。 + +### 升级边界 + +升级前遗留的 durable retry sidecar 若是在旧实现(审计记录 `stream=true`、实际发送 `stream=false`)期间创建,升级恢复后会按当前配置真实发送流式请求。该行为修正了配置与 wire 行为的一致性,但不保证与升级前已发出的失败请求字节一致;排查跨版本恢复时以 raw failure log 的请求快照为准。 + +--- diff --git a/docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md b/docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md index d3bd16972..1111073b8 100644 --- a/docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md +++ b/docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md @@ -24,7 +24,7 @@ - `docs/`:当前 PRD、架构、开发运维、设计和测试口径。 - `docs/project-memory/shared-memory/`:长期团队记忆、决策、流程和踩坑摘要。 -- `.hermes/`:Hermes 工具资源,不作为项目知识库。 +- `.codex/`:Codex 工具资源,不作为项目知识库。 - `.codex/skills/`:Codex 可复用技能;只在任务命中时读取。 - `scripts/rag/`:Agent 本地检索入口,只提供候选上下文。 @@ -73,11 +73,11 @@ RAG 默认不安装运行时依赖,也不把 LanceDB、Transformers.js 或本 ## SpacetimeDB 规则 -涉及 SpacetimeDB 设计、实现、脚本、调试、发布、绑定生成、schema、reducer、procedure、view 或 Rust API 时,先读取对应 skill: +涉及 SpacetimeDB 设计、实现、脚本、调试、发布、绑定生成、schema、reducer、procedure、view 或 API 时,先读取项目适配 skill: -- `.codex/skills/spacetimedb-cli/SKILL.md` -- `.codex/skills/spacetimedb-rust/SKILL.md` -- `.codex/skills/spacetimedb-concepts/SKILL.md` +- `.codex/skills/genarrative-spacetimedb/SKILL.md` + +该 skill 按任务范围路由到官方 SpacetimeDB 插件的 `spacetimedb:concepts`、`spacetimedb:rust-server`、`spacetimedb:cli`、`spacetimedb:typescript-client`、`spacetimedb:mcp` skill;项目边界覆盖插件通用示例。插件缺失时按项目 skill 中的安装命令补齐,个人插件配置、缓存和凭据不得进入仓库。 已有表新增字段时,字段必须放在 Rust 表结构体最后,并设置明确默认值。删除、改名、重排或改类型前必须先询问用户并确认迁移计划。 @@ -93,8 +93,8 @@ RAG 默认不安装运行时依赖,也不把 LanceDB、Transformers.js 或本 ## 技能路由 - 新增、补齐、迁移或重构玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model:读取 `.codex/skills/genarrative-play-type-integration/SKILL.md`。 -- 本地 dev 端口、代理目标、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理和后台 dev 串联:读取 `.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md`。 -- 仓库级 Hermes skills/plugins:先读 `.hermes/README.md`,只把 `.hermes/` 当工具目录。 +- 本地 dev 端口、代理目标、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理和后台 dev 串联:读取 `.codex/skills/genarrative-dev-stack-port-routing/SKILL.md`。 +- 仓库级 Codex skills/plugins:先读 `.codex/README.md`,只把 `.codex/` 当工具目录。 ## Issue 与提交 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index df3653c50..672d6c999 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -18,7 +18,7 @@ server-rs + Axum + SpacetimeDB `server-rs/Cargo.toml` 是 workspace 事实源。默认构建成员为 `crates/api-server`;第三方依赖版本和 workspace 内 crate path 统一放在 `[workspace.dependencies]`。 -SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`、`spacetimedb-lib` 统一锁定 `2.7.0`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings 和容器压测镜像也必须与 `server-rs/Cargo.toml` 锁定版本对齐,避免 BSATN / procedure result 反序列化错配。2.7.0 官方 CLI / standalone 发行包与容器镜像使用 `v2.7.0-hotfix3` 资产标签,二进制版本仍为 `2.7.0`;不得回退使用缺少后续 backing-view 迁移修复的裸 tag 构建。遇到版本不匹配时,不继续沿着业务超时排查,先把 CLI / standalone 直接升级到锁定版本并重启后再重试。2.6.1 还修复了 procedure context 中调用者 `Identity` / `ConnectionId` 丢失问题,因此依赖调用者身份的 procedure 不得继续运行在 2.6.0 standalone 上。 +SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`、`spacetimedb-lib` 统一锁定 `2.8.3`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings、容器压测镜像和生产 provision 也必须与 `server-rs/Cargo.toml` 锁定版本对齐,避免 BSATN / procedure result 反序列化错配。2.8.3 官方 CLI / standalone 发行包、Rust crates 与容器镜像使用同版本号;CLI / standalone 还必须核对 commit `8e410d2842147bd8e5a32a9589cc00c19f7478e2`。遇到版本不匹配时,不继续沿着业务超时排查,先把 CLI / standalone 直接升级到锁定版本并重启后再重试。2.6.1 修复了 procedure context 中调用者 `Identity` / `ConnectionId` 丢失问题;2.8.3 修复 scheduled function 从实际执行时间重排导致的长期漂移。 当前主要 crate: @@ -185,7 +185,7 @@ npm run check:server-rs-ddd 7. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server` 的 `spacetime-client` 长期订阅后读本地 cache。跨玩法公开作品统一主读模型是 `public_work_gallery_entry` 和 `public_work_detail_entry`;公开作品资产读取授权投影是 `public_work_asset_read_grant`;各玩法既有 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 保留为 source view 和兼容路径。短期不把作品列表整体交给浏览器前端直接订阅;不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表。需要请求时间窗口的轻量统计可订阅 `public_work_play_daily_stat` 后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍走玩法 procedure / reducer。前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得自己做 join、聚合或权限逻辑。首屏、排序、字段归一、权限降级和 HTTP fallback 由 `api-server` BFF 维持。 8. 多列索引按 SpacetimeDB 绑定生成的元组参数直接传入,例如 `.filter((source_type, profile_id, played_day))`;前缀查询只传前缀元组,例如 `.filter((scope_kind, scope_id.as_str()))`。不要为了绕过类型问题退回整表遍历。 9. procedure result 必须返回 typed snapshot / typed value。`spacetime-client` mapper 不得再通过 `row_json/session_json/work_json/items_json/run_json/event_json/feedback_json: Option` 做跨层 JSON 字符串传输,也不得在 mapper 里反序列化旧 `*JsonRecord` 兼容结构。业务内部持久化字段如 `profile_payload_json`、`levels_json` 等不属于 procedure result 载荷例外,仍按各自表契约处理。 -10. procedure 需要按调用者 identity 鉴权时,必须先从外层 `ProcedureContext::sender()` 捕获 caller,再把 caller 显式传入 `try_with_tx` 闭包内的事务函数。当前 workspace 锁定 SpacetimeDB `2.7.0`;鉴权边界不得依赖事务上下文的隐式 sender 语义,即使 SDK 升级也继续保持显式 caller 参数。 +10. procedure 需要按调用者 identity 鉴权时,必须先从外层 `ProcedureContext::sender()` 捕获 caller,再把 caller 显式传入 `try_with_tx` 闭包内的事务函数。当前 workspace 锁定 SpacetimeDB `2.8.3`;鉴权边界不得依赖事务上下文的隐式 sender 语义,即使 SDK 升级也继续保持显式 caller 参数。 11. 修改后运行: ```bash @@ -232,7 +232,7 @@ npm run check:server-rs-ddd 3. 结果页单图重生成、发布、道具使用和其它独立资产操作仍按各自业务操作成本执行;不要把初始草稿成本误套到这些单次操作上。 4. 资产操作的预扣费必须 fail-closed:钱包或 SpacetimeDB 预扣费不可达、超时或返回业务错误时,`api-server` 直接返回错误,不允许继续调用图片、音频、GLB 等外部生成 provider。 5. 需要支持 HTTP retry 的计费 ledger id 必须包含当前请求的 `request_id`;前端 `fetchWithApiAuth` 同一次业务请求的静默刷新重试复用同一个 `x-request-id`,后端不得再使用 prompt 指纹或随机 asset id 作为扣费幂等键。 -6. 外部生成已预扣费但后续失败时必须先同步调用钱包退款;若 SpacetimeDB 暂不可用,退款请求写入 `wallet-refund-outbox` 本地文件并由后台 worker 重放。默认启用,配置项为 `GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED`、`GENARRATIVE_WALLET_REFUND_OUTBOX_DIR`、`GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE`、`GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS` 和 `GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES`。outbox 文件按 refund ledger id 幂等落盘;成功重放后删除,坏文件隔离为 `corrupt-*`。外部生成任务触发的扣费和退款必须在 `profile_wallet_ledger.metadata_json` 中写入 `externalGenerationJobId`,outbox 重放也必须保留同一任务 ID,便于从退款记录追溯到正式生成任务。 +6. 外部生成已预扣费但后续失败时,失败/任务状态变更事务必须在 SpacetimeDB 内按 refund ledger id 幂等写入 `profile_wallet_refund_outbox` pending 行;跨节点 worker 从库内 pending 行批量处理并在库内事务执行退款,成功后删除 outbox 行,失败按 `available_at` 和 `attempts` 重试。当前 attempt 若 consume 尚不可见,事务仍必须先写 `asset_operation_wallet_settlement` 取消 intent,阻止迟到扣费。普通 inline 资产失败也先调用同一 DB outbox procedure;只有 SpacetimeDB 完全不可达时才写 `wallet-refund-outbox` 本机 emergency spool。默认启用,配置项为 `GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED`、`GENARRATIVE_WALLET_REFUND_OUTBOX_DIR`、`GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE`、`GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS` 和 `GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES`。本机文件按 refund ledger id 幂等落盘;成功重放后删除,坏文件隔离为 `corrupt-*`,不能替代库内 outbox。外部生成任务触发的扣费和退款必须在 `profile_wallet_ledger.metadata_json` 与两类 outbox 中保留 `externalGenerationJobId` 和 `externalGenerationClaimAttempt`,便于从退款记录追溯到具体 attempt。 7. 拼图首图后台生成的跨实例互斥锁必须落在 SpacetimeDB `puzzle_background_compile_task` 表,claim id 由 `task_id + request_id` 构成,释放时必须校验 claim id,避免旧后台任务释放新请求抢到的租约。 ## 用户钱包与编辑器生成扣费契约 @@ -329,6 +329,8 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`AiTask` - 源码:`server-rs/crates/spacetime-module/src/ai/tasks.rs` +- `module-ai` 的进程内热状态不是持久化真相:文本增量按阶段有序聚合并受单阶段 512 KiB、每阶段 8192 个 chunk 上限约束;terminal task 立即释放增量明细,内存工作集最多保留 1024 个任务。需要长期查询时必须读取 SpacetimeDB 的 `ai_task` / `ai_task_stage` 投影,不得依赖进程重启后仍存在的内存快照。 +- SpacetimeDB 的 AI 写入 procedure 必须复用同一组任务元数据、payload、文本、结构化输出、warning、失败消息和结果引用上限;流式聚合超过 512 KiB 或 8192 个 chunk 时在事务内拒绝,terminal task 收口后分批删除 `ai_text_chunk` 明细,只保留阶段最终快照和结果引用。 ### `ai_task_event` @@ -360,18 +362,20 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` - 用途:外部生成正式任务列表的轻量投影,按 `job_id` 保存 owner、来源、状态、可选 `phase`、价格、有界错误摘要、通知确认时间、各阶段时间和入队时提取的 `request_prompt`,不包含 request/result payload、worker lease 或 dedupe 内部字段。错误摘要统一拒绝内联媒体并限制为 2048 字符;列表在单次 owner 扫描中同时计数并只保留请求 limit 的固定大小 top-N,不得先收集全量历史再截断。enqueue、claim、renew、phase update、complete、fail 事务同步投影;acknowledge 只更新该轻量表并写审计事件,后续主任务同步必须保留已有确认时间,禁止为了写确认时间加载 / 重写大 payload 行。BFF 的列表、状态和确认只调用 summary procedure;`running + processing` 映射为“正在处理”,其它 running(含旧行 `phase=None`)映射为“正在生成”。历史终态任务由迁移操作员的游标分批 maintenance procedure 在压缩 payload 时同步回填摘要,正式列表不得为兼容旧数据回扫完整主表。 - 非阻断告警:摘要字段 `warning_message` 是展示投影,由完成任务的轻量 `result_payload_json.warning.reason` 原样提取,不等同于公开 inline / external v1 的原始结构化诊断字段。complete 和历史 backfill 共用同一构建路径;历史任务按其结果载荷中已写入的 `reason` 快照投影,不为格式升级重写或补前缀。单 job 状态和任务列表 BFF 以 `warning: string` 返回该可直接展示的完整文案,不再返回结构化 code,Web 不得再次补前缀或按字符串推断告警类型。错误与告警摘要都不复制内联媒体并限制为 2048 字符。`phase` 与 `warning_message` 分别表示当前执行阶段和成功降级提示,不得混用;worker / BFF / Web 必须同版本协调发布,不保证滚动混部或旧 Web 缓存下的字符串语义兼容。 -- 正式读取 procedure 为 `get_external_generation_job_summary_and_return`、`list_external_generation_job_summaries_and_return` 和 `acknowledge_external_generation_job_summaries_and_return`。历史维护 procedure 为 `compact_external_generation_job_payloads_and_return` 与 `backfill_external_generation_job_summaries_and_return`,仅 migration operator 可调用;运维入口统一使用 `npm run spacetime:external-generation:maintain -- ...`,默认 dry-run、单批最多 25 条。B-tree cursor 选择阶段最多反序列化 `limit + 1` 行,apply 再按主键逐条读取选中行;怀疑存在单行异常巨型 JSON 时必须先使用 `--limit 1`。payload 压缩额外固定使用 `source_module = editor-canvas` 的复合 cursor 索引,不得静默改写其它玩法历史任务。 +- 正式读取 procedure 为 `get_external_generation_job_summary_and_return`、`list_external_generation_job_summaries_and_return` 和 `acknowledge_external_generation_job_summaries_and_return`。历史维护 procedure 为 `compact_external_generation_job_payloads_and_return`、`backfill_external_generation_job_summaries_and_return` 与 `prune_external_generation_job_history_and_return`,仅 migration operator 可调用;运维入口统一使用 `npm run spacetime:external-generation:maintain -- ...`,默认 dry-run、单批最多 25 条。B-tree cursor 选择阶段最多反序列化 `limit + 1` 行,apply 再按主键逐条读取选中行;怀疑存在单行异常巨型 JSON 时必须先使用 `--limit 1`。payload 压缩额外固定使用 `source_module = editor-canvas` 的复合 cursor 索引,不得静默改写其它玩法历史任务。历史清理默认使用 `--prune-history`、`source_module = editor-canvas` 和 30 天保留期;只有主任务与摘要状态一致且属于 completed / failed / cancelled、摘要已有 `notification_acknowledged_at`、终态时间不晚于 cutoff 的记录才是候选。apply 在同一事务内按事件 → 摘要 → 主任务顺序删除,事件不得独立清理;每次事务最多删除 256 条事件,若同一任务仍有事件则保留任务与摘要并返回同一个 `next_cursor_job_id`,下一次继续该任务,避免单个任务形成无界事务写集;pending / running、未确认通知、摘要缺失或状态不一致的记录永不删除。清理不触碰资产对象或钱包流水,其他 source module 必须显式指定并单独评估。 ### `external_generation_job_event` - Rust 结构体:`ExternalGenerationJobEvent` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` - 用途:外部生成任务审计事件表,按 `job_id` 和 `owner_user_id` 记录 `enqueued`、`claimed`、`lease_renewed`、`completed`、`failed`、`acknowledged` 等状态转换事实。状态转换只能由 SpacetimeDB procedure 写入,不由前端或 worker 直接改表;该表用于追溯任务生命周期和排障,不替代 `external_generation_job` 当前状态。 +- 保留策略:事件只会随已确认通知的终态任务由 `prune_external_generation_job_history_and_return` 原子删除,不支持按事件单独清理,以保持任务、摘要和审计链一致。单次事务最多删除 256 条事件;若事件未删完,任务和摘要暂不删除,维护脚本用同一个 job cursor 重试剩余事件。 ### `ai_text_chunk` - Rust 结构体:`AiTextChunk` - 源码:`server-rs/crates/spacetime-module/src/ai/stages.rs` +- 单阶段最多保留 8192 个 chunk;聚合和终态清理均按有界批次处理,避免小 delta 堆积为无界行数或一次性 ID 列表。 ### `analytics_date_dimension` @@ -410,12 +414,18 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 ### `auth_store_projection_meta` +启动投影恢复会对过滤后的 retained refresh session 重新计数;超过 8192 条时直接失败关闭并继续重试,不得把超限快照一次性灌入内存。 + - Rust 结构体:`AuthStoreProjectionMeta` - 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs` +- 职责:保存 typed 认证投影的单调版本,以及短期手机号验证码和微信 OAuth state 的序列化投影;`phone_codes_json` / `wechat_states_json` 只承载短期认证状态,不替代 `user_account`、`auth_identity` 或 `refresh_session` 的正式表语义。 -认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)导出 typed `AuthStoreProjectionView`,再恢复 `module-auth` 的进程内认证工作集;运行中 Bearer `sid` 或 refresh cookie 在本进程工作集内未命中时直接按失效处理,不再从 SpacetimeDB 导出整包认证状态刷新内存,避免旧投影把重复手机号或旧会话重新灌回进程。`module-auth` 只保留内存工作集和 projection 导入 / 导出能力,不再保留 JSON 快照导入 / 导出能力,也不写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前通过 `sync_auth_store_projection` 成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 会按固定间隔持续重试认证工作集恢复,恢复成功后才开始监听 HTTP,避免一次短超时让进程永久停留在依赖不可用状态。 +认证恢复策略:`api-server` 启动时从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)以及 `auth_store_projection_meta` 中的短期状态投影导出 typed `AuthStoreProjectionView`,再恢复 `module-auth` 的进程内认证工作集;生产 Bearer 中间件不再从 `InMemoryAuthStore` 读取用户或会话,而是每次通过 typed `validate_auth_session` procedure 在 SpacetimeDB 事务内校验 `token_version`、会话归属、撤销时间和过期时间,SpacetimeDB 不可用时 fail closed 返回服务错误。`validate_auth_session`、投影导出和投影同步均从 `ctx.sender()` 派生调用方,并复用现役 runtime service identity 白名单;启动恢复先完成该服务身份初始化,普通 SpacetimeDB identity 不能读取或改写私有认证表。测试构建仍可使用显式的内存测试夹具。所有会读取或变更本机认证工作集的认证主链路(登录、刷新、`/me`、会话管理、密码、绑定和微信 state)在领域操作前先从正式投影做一次受 CAS 保护的只读刷新,刷新失败时 fail closed;refresh cookie 仍只按正式 `refresh_session` 校验,其他认证数据也不得绕过正式同步。`module-auth` 只保留内存工作集和 projection 导入 / 导出能力,不再保留 JSON 快照导入 / 导出能力,也不写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作仍必须在返回客户端前通过 `sync_auth_store_projection` 成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号、会话、短信验证码或微信 state 当成成功结果。每个 API 工作集绑定启动恢复或上次成功同步得到的 `auth_store_projection_meta.updated_at` 版本作为 `base_updated_at_micros`,SpacetimeDB 在同一事务内执行基线 CAS,并要求新的 `updated_at_micros` 严格递增;基线不一致或版本不晚于当前值时整包写入失败,冲突节点只有在确认本次同步尝试期间没有新的本地认证变更后,才可丢弃失败工作集并从正式表恢复,不能用陈旧工作集删除、恢复或覆盖另一节点的新状态;若同期仍有本地变更则保留 pending revision,并由后续认证请求先重试同步,不把临时数据库故障变成永久卡死;同步成功但期间又出现新本地变更时最多连续补同步三轮,仍未稳定则失败关闭。这只是迁移期并发保护,不改变正式认证表的权威地位。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 会按固定间隔持续重试认证工作集恢复,恢复成功后才开始监听 HTTP,避免一次短超时让进程永久停留在依赖不可用状态。 +认证工作集容量限制:refresh session 最多保留 8192 条,短信验证码最多保留 4096 条;写入前清理过期项,达到上限时拒绝新增而不继续膨胀。 -`auth_store_snapshot` 表和旧 `import_auth_store_snapshot_json` / `export_auth_store_snapshot_from_tables` procedure 已删除。认证投影同步只读写 `user_account`、`auth_identity`、`refresh_session` 和 `auth_store_projection_meta`;`auth_identity` 不再写 `phone_e164`、`display_name`、`avatar_url`,这些账号资料只以 `user_account` 为准。 +`auth_store_snapshot` 表和旧 `import_auth_store_snapshot_json` / `export_auth_store_snapshot_from_tables` procedure 已删除。认证投影同步只读写 `user_account`、`auth_identity`、`refresh_session` 和 `auth_store_projection_meta`;`auth_identity` 不再写 `phone_e164`、`display_name`、`avatar_url`,这些账号资料只以 `user_account` 为准。`api-server` 多节点必须使用相同的部署级验证码哈希盐(当前复用 `GENARRATIVE_JWT_SECRET`);轮换该 secret 会使尚未消费的短信验证码失效,但不会改变已持久化账号或 session。 + +短期状态的并发保护:发短信前先从正式投影刷新工作集,再写入不可消费的占位验证码并通过 `sync_auth_store_projection` 的基线 CAS 占用手机号 / 场景冷却窗口;只有占用成功后才调用外部短信 provider,provider 成功后再同步真实验证码哈希。微信 OAuth state 在 `module-auth` 工作集内限制活动数量,超过上限直接拒绝创建,避免单行 JSON 投影无界增长;过期 state 仍由投影导出时清理。 ### `bark_battle_draft_config` @@ -762,7 +772,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 说明:图片画布工程、素材文件夹和工程资源首次创建的私有 durable receipt。主键 `receipt_key` 由认证 owner、接口 namespace 与 API 根据 `Idempotency-Key` 派生的请求记录 ID 做 domain-separated SHA-256 得到;`request_sha256` 覆盖完整规范化 create payload,但不包含每次重试都会变化的请求时间。首次业务行、副作用与 receipt 必须在同一 SpacetimeDB 事务中提交,主键唯一约束负责并发仲裁。 - 重放:同 owner、namespace、key 与同一原始规范化正文重放时,按 receipt 的 `result_record_id` 返回当前业务行,因此工程改名、目录更新或资源元数据后仍不会把当前可变行误判为请求正文漂移;同键异正文返回 `409`。receipt 不随业务行删除,首次结果已删除时重放统一返回 `409` 并拒绝重建;receipt 缺失但请求稳定 ID 已存在同样失败关闭,不能补造 receipt 或重复首次副作用。 - 索引:主键 `receipt_key`;`by_editor_idempotent_create_receipt_owner(owner_user_id, namespace, request_record_id)` 仅用于受控诊断。表为 private,不作为工程、目录或资源 read model。 -- 真实事务门禁:运行 `npm run check:editor-idempotency-procedures`,在隔离的 SpacetimeDB 2.7.0 hotfix3 standalone 中发布当前模块,验证工程、素材文件夹和工程资源的同正文重放、异正文冲突、并发仲裁、删除后失败关闭,以及业务行与 private receipt 一一对应且无孤儿;源码字符串断言不能替代该门禁。 +- 真实事务门禁:运行 `npm run check:editor-idempotency-procedures`,在隔离的 SpacetimeDB 2.8.3 standalone 中发布当前模块,验证工程、素材文件夹和工程资源的同正文重放、异正文冲突、并发仲裁、删除后失败关闭,以及业务行与 private receipt 一一对应且无孤儿;源码字符串断言不能替代该门禁。 ### `editor_generation_runtime_identity_rotation` @@ -1057,6 +1067,13 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 说明:资产操作 consume/refund 配对结算事实表,主键为 consume ledger ID,并保存配对 refund ledger、用户、金额和结算时间。退款先到且 consume 尚不可见时,该表作为持久化取消 intent;迟到 consume 必须检测该行并拒绝扣费,避免 worker 崩溃重领期间双扣。 - 索引:主键 `consume_ledger_id`。 +### `profile_wallet_refund_outbox` + +- Rust 结构体:`ProfileWalletRefundOutbox` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` +- 说明:跨节点资产退款的正式 pending 队列。主键为 refund ledger ID,保存 consume/refund 配对、用户、金额、资源、生成任务 attempt、失败原因和重试时间;失败事务先写入该表,worker 在 SpacetimeDB 事务内幂等执行钱包退款并删除成功行。只有数据库不可达时,api-server 才使用本机 `wallet-refund-outbox` emergency spool;本机 `MAX_BYTES` 达到阈值时改写入同目录 `refund-overflow-*` 溢出文件,保持可恢复而不静默丢弃。 +- 索引:`(status, available_at)`。 + ### `profile_wallet_config` - Rust 结构体:`ProfileWalletConfig` @@ -1232,6 +1249,7 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`; - Rust 结构体:`RefreshSession` - 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs` +- 认证工作集只保留 active 会话以及最近 24 小时内的 revoked / expired 会话;超过宽限期的失效会话在 refresh session 写路径和 projection 导出前从内存索引移除,并随下一次 typed projection 同步从正式表清理。该清理不改变 active 多端登录、单端登出或全端登出语义。 ### `runtime_setting` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 7de599e9c..eaca00df3 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -99,7 +99,7 @@ HTTP 角色的 `GENARRATIVE_SPACETIME_POOL_SIZE` 只表示 procedure / reducer 生产拆分角色时,`external-generation-worker` 和 `external-generation-controller` 的专属 env 示例会把 `GENARRATIVE_SPACETIME_POOL_SIZE` 覆盖为 `1`;非 HTTP 角色不创建 API 缓存读连接,只保留 `external_generation_job` 队列窄订阅作为响应式唤醒信号,实际抢占和扩缩容判断仍走 SpacetimeDB procedure。worker / controller 不执行模型定价 seed,启动时先调用受 runtime writer 鉴权的 queue-stats procedure 做只读预检,身份不匹配时 fail-fast;当前正式 systemd unit 通过共同加载 API env 继承同一 `GENARRATIVE_SPACETIME_TOKEN`,默认路径为 `/etc/genarrative/api-server.env`,自定义部署由 provision 和 API deploy 按实际参数渲染,专属角色 env 示例不重复配置该 token。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 与 controller poll interval 只作为订阅失效、漏事件和 lease 过期这类时间条件的兜底,不作为正常领取任务的主路径。 -生产 worker 默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=600`,只覆盖 worker 心跳抖动和短暂断连窗口,不再把 lease 当成完整任务时长;默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS=900`。`editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction` 四类 VectorEngine 图片任务与角色动画 / 视频类长任务使用 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800`,手动去背景、音效和背景音乐继续使用普通预算。worker 在单次尝试超过执行预算后会停止续租并释放 worker 槽位,但不会取消已启动的业务 future 或主动写入失败 / 重试状态;在途执行由 lease fencing 仲裁,有效租约内写回仍可完成,租约过期后任务才可重新领取,attempt 耗尽时由认领事务标记失败并结算退款。生产部署和 provision 脚本会给 `/etc/genarrative/api-server.env` 与 `/etc/genarrative/external-generation-worker.env` 补齐这些变量;已有自定义值不覆盖,只会把历史旧默认 `3600` 迁移为 `600`。 +生产 worker 默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=600`,只覆盖 worker 心跳抖动和短暂断连窗口,不再把 lease 当成完整任务时长;默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS=900`。`editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction` 四类 VectorEngine 图片任务与角色动画 / 视频类长任务使用 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800`,手动去背景、音效和背景音乐继续使用普通预算。worker 在单次尝试超过执行预算后会停止续租,但不会取消已启动的业务 future 或主动写入失败 / 重试状态;执行许可会一直绑定到 active 或 detached work 真正结束(或超过租约仲裁窗口被取消),避免超时任务脱管后立即补进新的高内存任务。在途执行由 lease fencing 仲裁,有效租约内写回仍可完成,租约过期后任务才可重新领取,attempt 耗尽时由认领事务标记失败并结算退款。生产部署和 provision 脚本会给 `/etc/genarrative/api-server.env` 与 `/etc/genarrative/external-generation-worker.env` 补齐这些变量;已有自定义值不覆盖,只会把历史旧默认 `3600` 迁移为 `600`。 lease 过期后不代表任务一定再次执行:claim transaction 只有在 `attempt < max_attempts` 时才会递增 attempt 并返回 worker;如果过期的是最终 attempt,则直接把 job 收口为 `failed`、清理 lease,并按入队冻结价格为当前 attempt 原子退款或写 cancellation intent。该终态任务不会再次进入 provider executor,迟到 consume 会被 settlement intent 拒绝。 @@ -115,9 +115,9 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去 图片编辑器任务侧栏与生成提交工作流只读取 BFF 队列接口:`GET /api/runtime/external-generation/jobs` 列出当前用户任务,`GET /api/runtime/external-generation/jobs/{jobId}` 查看单 job 状态,概览场景可使用 `GET /api/runtime/external-generation/queue-overview`。前端不直接查询 `external_generation_job` private table,也不展示 worker 内部 payload;完成态以编辑器项目和资源接口返回的正式数据为准。 -外部生成任务摘要投影与历史 payload 维护使用 `npm run spacetime:external-generation:maintain -- ...`,且只能由已授权 migration operator 的 SpacetimeDB CLI 登录态执行。脚本默认 dry-run、每次只处理一批,绝不自动循环全表;`--apply` 才写入。先发布包含 `external_generation_job_summary` 与 cursor 索引的 SpacetimeDB 模块,在维护模式内对事故时间以前的编辑器终态任务执行小批 dry-run,例如 `npm run spacetime:external-generation:maintain -- --database --server-url --limit 5 --completed-before-micros `;核对 `matched_count`、`before_bytes`、`after_bytes` 和 `inline_media_count` 后,保持本批输入 cursor 不变并追加 `--apply` 重跑同一批,即使最后一批 `has_more = false`,只要 dry-run 仍有 `matched_count` / `selected_count` 也必须 apply;只有 apply 成功后才使用它返回的 `next_cursor_job_id` 继续。B-tree cursor 的选择阶段最多反序列化 `limit + 1` 行,apply 会再按主键逐条读取选中行但不会同时保留整批 payload;如怀疑存在单行异常巨型历史 JSON,先用 `--limit 1`。payload 压缩硬限制 `source_module = editor-canvas`;终态压缩完成后,用 `--backfill-summaries` 先 dry-run、再 `--apply` 分批补齐仍缺失的活动任务或无内联媒体历史任务摘要,直到 `has_more = false`,最后再切换使用 summary procedure 的 api-server。Stdb 构建 artifact 和完整 release 包都必须包含 `scripts/spacetime-maintain-external-generation-jobs.mjs` 与 `scripts/spacetime-migration-common.mjs`。首次上线不得让 Full Build 从 Stdb 自动直落 API:`STDB_API_ROLLOUT_MODE` 默认 fail-closed 为 `pause-after-stdb`,必须填写受限的 `STDB_API_ROLLOUT_APPROVERS`;Stdb Publish 通过 `KEEP_MAINTENANCE_MODE` 保持维护文件并停止旧 API/controller/worker,暂停点最多等待 4 小时,完成上述维护并确认无后续批次后才由指定审批人放行 API。定时构建缺少审批人时必须在发布前失败,不能静默退回 `normal`;也可分开运行 Stdb publish、维护、API deploy 三个受控 Job。任一批次都不得处理 pending / running payload;不要用 runtime writer、bootstrap secret 或匿名 identity 代替 migration operator,也不要在未核对 dry-run 时直接 apply。 +外部生成任务摘要投影与历史 payload / history 维护使用 `npm run spacetime:external-generation:maintain -- ...`,且只能由已授权 migration operator 的 SpacetimeDB CLI 登录态执行。脚本默认 dry-run、每次只处理一批,绝不自动循环全表;`--apply` 才写入。先发布包含 `external_generation_job_summary` 与 cursor 索引的 SpacetimeDB 模块,在维护模式内对事故时间以前的编辑器终态任务执行小批 dry-run,例如 `npm run spacetime:external-generation:maintain -- --database --server-url --limit 5 --completed-before-micros `;核对 `matched_count`、`before_bytes`、`after_bytes` 和 `inline_media_count` 后,保持本批输入 cursor 不变并追加 `--apply` 重跑同一批,即使最后一批 `has_more = false`,只要 dry-run 仍有 `matched_count` / `selected_count` 也必须 apply;只有 apply 成功后才使用它返回的 `next_cursor_job_id` 继续。B-tree cursor 的选择阶段最多反序列化 `limit + 1` 行,apply 会再按主键逐条读取选中行但不会同时保留整批 payload;如怀疑存在单行异常巨型历史 JSON,先用 `--limit 1`。payload 压缩硬限制 `source_module = editor-canvas`;终态压缩完成后,用 `--backfill-summaries` 先 dry-run、再 `--apply` 分批补齐仍缺失的活动任务或无内联媒体历史任务摘要,直到 `has_more = false`,最后再切换使用 summary procedure 的 api-server。历史清理使用 `--prune-history`,默认 `source_module=editor-canvas`、30 天保留期;候选必须是 completed / failed / cancelled 终态、主任务与摘要状态一致、摘要存在 `notification_acknowledged_at` 且终态时间不晚于 `completed_before_micros`,否则永不删除。先 dry-run,记下输出的 `completed_before_micros`,再保持相同 `--cursor-job-id` 与 cutoff 追加 `--apply`;apply 在一个事务中删除该 job 的所有 event、summary 和主任务,资产对象与钱包流水保留。需要清理其它 source module 时必须显式 `--source-module` 并先完成业务评估;这不是自动 systemd 任务,不得授予 runtime writer 清理权限。Stdb 构建 artifact 和完整 release 包都必须包含 `scripts/spacetime-maintain-external-generation-jobs.mjs` 与 `scripts/spacetime-migration-common.mjs`。首次上线不得让 Full Build 从 Stdb 自动直落 API:`STDB_API_ROLLOUT_MODE` 默认 fail-closed 为 `pause-after-stdb`,必须填写受限的 `STDB_API_ROLLOUT_APPROVERS`;Stdb Publish 通过 `KEEP_MAINTENANCE_MODE` 保持维护文件并停止旧 API/controller/worker,暂停点最多等待 4 小时,完成上述维护并确认无后续批次后才由指定审批人放行 API。定时构建缺少审批人时必须在发布前失败,不能静默退回 `normal`;也可分开运行 Stdb publish、维护、API deploy 三个受控 Job。任一批次都不得处理 pending / running payload;不要用 runtime writer、bootstrap secret 或匿名 identity 代替 migration operator,也不要在未核对 dry-run 时直接 apply。 -角色动作正式字段收口使用 `node scripts/spacetime-normalize-editor-character-actions.mjs --database --server-url `,且同样只能由已授权 migration operator 执行。必须先发布包含 normalization cursor 索引和 `normalize_editor_character_animation_metadata_and_return` 的 SpacetimeDB 模块,在 API / worker 仍处于维护模式时先运行默认全量 dry-run;脚本固定按 `asset → project-resource → showcase → canvas` 扫描,普通 scope 每批最多 25 行,canvas 每批最多 5 行。全量 dry-run 会在不写库的情况下把 asset 计划结果投影给同 owner / task / 首帧对象精确匹配的 project-resource,再把前置 scope 的计划结果投影给 canvas 检查;因此同 task 的误标预览 MP4 会先按权威视频对象排除,最终图片序列会逐帧核对并补齐精确 `asset_object` 身份。canvas 中仍引用误标 preview resource 的普通 video layer 会按 project-resource 计划态 `video` 跳过,只有 layout 明确声明动作却指向视频,或资源规划本身失败时才形成 blocker。apply 时仍要求前置 scope 已按顺序物理完成,不能跳过 asset 直接让 project-resource 借未落库结果。历史 canvas 复制的 `sourceResourceId` 不是迁移证据,不要因它仍指向原角色而手工改库,补建资源会采用最终账号素材的 DB 血缘。出现 blocker 时脚本会打印 ID、原因、owner、project、task、对象身份和来源资源;先据此区分最终候选为零 / 多个、正式与旧版冲突、帧对象不匹配或缺失资源,不得跳过 scope。确认 dry-run 后追加 `--apply`,脚本会对每批重新 dry-run、携带该批 SHA-256 apply,并在最后从头要求四个 scope 均为零匹配、零 blocker。只有该复核通过后才发布移除 action fallback 的 API / Web。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-normalize-editor-character-actions.mjs` 与 `scripts/spacetime-migration-common.mjs`。本地切换分支时若要避免 dev publish 因 schema 冲突使用 `-c=on-conflict` 清库,启动命令必须追加 `--preserve-database`,让冲突直接失败。 +角色动作正式字段收口使用 `node scripts/spacetime-normalize-editor-character-actions.mjs --database --server-url `,且同样只能由已授权 migration operator 执行。必须先发布包含 normalization cursor 索引和 `normalize_editor_character_animation_metadata_and_return` 的 SpacetimeDB 模块,在 API / worker 仍处于维护模式时先运行默认全量 dry-run;脚本固定按 `asset → project-resource → showcase → canvas` 扫描,普通 scope 每批最多 25 行,canvas 每批最多 5 行。全量 dry-run 会在不写库的情况下把 asset 计划结果投影给同 owner / task / 首帧对象精确匹配的 project-resource,再把前置 scope 的计划结果投影给 canvas 检查;因此同 task 的误标预览 MP4 会先按权威视频对象排除,最终图片序列会逐帧核对并补齐精确 `asset_object` 身份。canvas 中仍引用误标 preview resource 的普通 video layer 会按 project-resource 计划态 `video` 跳过,只有 layout 明确声明动作却指向视频,或资源规划本身失败时才形成 blocker。apply 时仍要求前置 scope 已按顺序物理完成,不能跳过 asset 直接让 project-resource 借未落库结果。历史 canvas 复制的 `sourceResourceId` 不是迁移证据,不要因它仍指向原角色而手工改库,补建资源会采用最终账号素材的 DB 血缘。出现 blocker 时脚本会打印 ID、原因、owner、project、task、对象身份和来源资源;先据此区分最终候选为零 / 多个、正式与旧版冲突、帧对象不匹配或缺失资源,不得跳过 scope。确认 dry-run 后追加 `--apply`,脚本会对每批重新 dry-run、携带该批 SHA-256 apply,并在最后从头要求四个 scope 均为零匹配、零 blocker。只有该复核通过后才发布移除 action fallback 的 API / Web。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-normalize-editor-character-actions.mjs` 与 `scripts/spacetime-migration-common.mjs`。本地切换分支时若要避免 dev publish 因 schema 冲突使用 `-c=on-conflict` 清库,启动命令必须追加 `--preserve-database`,让冲突直接失败。动作视频抽帧临时目录固定使用 `/var/lib/genarrative/character-animation-tmp`,该路径已由生产 API / worker unit 放行;不要让动作抽帧重新依赖 `PrivateTmp` 下的 `/tmp`。 普通图片错误素材类型清理使用 `npm run spacetime:editor-image-asset-kind:clean -- --database --server-url `,只能由已授权 migration operator 执行。先进入维护模式并发布包含 `clean_editor_image_asset_kind_and_return` 的 SpacetimeDB module,并保持旧版本 API / controller / worker 停止;随后运行默认全量 dry-run,核对 `asset → project-resource → showcase → canvas` 各 scope 的扫描数、命中行数、字段数和 blocker 均符合预期,再追加 `--apply`。脚本对每批重新 dry-run、绑定包含画布迁移摘要、结构化 layer 与 generation-dialog 权威 JSON 的 SHA-256,最后自动从头复核零命中;任一画布数据异常都会只输出哈希化 ID、scope 与原因并停止,不能跳过。清理只处理精确业务旧值,不修改 `asset_object.asset_kind`、MIME 或媒体类型;project-resource scope 在清行前验证同工程 migration 并将其状态纳入批次 hash,layout version 0 的 legacy 画布可以没有 migration,但 structured 画布缺 migration 必须立即形成 blocker,资源行不得先被清空;清行后能保持原 status 不变量时立即刷新摘要,否则只允许留给后续精确 canvas 字段清理收口。canvas scope 在任何布局写入前再次按 active / backfilled / rolled_back 状态验证原 migration 凭证和双份 legacy / structured 不变量,将 `editor_canvas_generation_dialog.dialog_json` 与 layer rows 一并扫描并在同一事务 patch;只允许本批资源清零及精确字段删除造成的差异,写入后从全部结构化权威行重建 layout、再次复核新状态才受控重签摘要,同时保持业务 revision、migration status 与全部时间戳不变。新版本 API、SpacetimeDB storage 创建入口、legacy 画布元数据提取和项目资源落表边界都会将 trim 后精确等于 `image` 的 `assetKind` 归一为 `NULL`,防止旧页面、滞留请求或 legacy 保存重新制造废弃值。完成零残留复核,并分别确认 cleaned backfilled 可激活、active 可继续保存、rolled_back 可重复复检后恢复应用版本,最后退出维护。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-clean-editor-image-asset-kind.mjs` 与 `scripts/spacetime-migration-common.mjs`。 @@ -206,7 +206,7 @@ spacetime sql "SELECT * FROM runtime_setting LIMIT 1" --server http:/ 本地 `npm run dev:spacetime` 发布模块时必须显式忽略仓库根目录的 `spacetime.json`,由脚本固定追加 `--no-config` 并使用命令参数里传入的数据库名和 `--server http://127.0.0.1:3101`。否则 CLI 可能把发布目标改写到配置文件里的其他数据库,导致 `dev:spacetime` 启动后又因发布失败自动退出,浏览器随后会在 `ws://127.0.0.1:3101/v1/database/.../subscribe` 看到连接拒绝。 -本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.7.0`。官方发行包位于 `v2.7.0-hotfix3` 资产标签,二进制仍报告 `2.7.0`;运行态和 provision 必须使用该 hotfix 构建,不得只按裸 `v2.7.0` tag 下载。当前 updater 元数据可能让 `spacetime version install 2.7.0` 装到裸 tag commit `a08663c7...`,所以 2.7.0 安装后必须核对 `spacetime --version` 的 commit 为 hotfix3 `d220349a...`;不一致时改用官方 hotfix3 archive 或仓库 provision 流程。若版本错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为现役 settings、editor project 或 profile procedure 超时。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;其它版本可执行 `spacetime version install && spacetime version use `,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会把 tool version 和 commit 一起写入 `dev-spacetime-tool-version`,启动新 standalone 与复用已有本地进程时都要求 `2.7.0 + d220349a...` 同时匹配;旧单行版本记录会拒绝复用并要求重启。2.6.1 修复了 procedure context 中调用者 `Identity` / `ConnectionId` 始终为空的回归,依赖 `ctx.sender` 鉴权时必须同时确认宿主已升级。 +本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.8.3`,CLI / standalone commit 固定核对为 `8e410d2842147bd8e5a32a9589cc00c19f7478e2`。若版本或 commit 错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为现役 settings、editor project 或 profile procedure 超时。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;其它版本可执行 `spacetime version install && spacetime version use `,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会把 tool version 和 commit 一起写入 `dev-spacetime-tool-version`,启动新 standalone 与复用已有本地进程时都要求 `2.8.3 + 8e410d28...` 同时匹配;旧版本或旧单行版本记录会拒绝复用并要求重启。2.6.1 修复了 procedure context 中调用者 `Identity` / `ConnectionId` 始终为空的回归,依赖 `ctx.sender` 鉴权时必须同时确认宿主已升级。 本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 VectorEngine 生成链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。业务模型和 VectorEngine provider 首选请求都使用 `gpt-image-2`,符合条件时才回退到兜底模型 `gpt-image-2-c`;图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image-2 family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。 @@ -312,7 +312,7 @@ npm run check:admin-account-procedures 项目已安装 `@colbymchenry/codegraph` 作为开发期依赖,用于在本地生成语义代码索引,辅助 AI / IDE 做符号搜索、调用关系和影响范围分析。索引目录为 `.codegraph/`,其中 `config.json` 可提交,数据库、缓存和日志由 `.codegraph/.gitignore` 保持本机私有。 -项目文档 RAG 索引使用 `scripts/rag/` 下的脚本和本地 `.rag/` 运行时目录,主要供 Agent 检索项目上下文,不作为人工阅读入口。默认不安装 RAG 相关依赖,不把 LanceDB、Transformers.js 或本地 embedding 模型写入根 `package.json`;需要启用时,Agent 必须先询问用户是否安装,并在用户确认后只安装到 gitignored 的 `.rag/runtime/`。索引范围默认包含 `AGENTS.md`、`CONTEXT.md`、`docs/project-memory/` 和 `docs/`,不把 `.hermes/` 工具目录作为项目知识库索引源。 +项目文档 RAG 索引使用 `scripts/rag/` 下的脚本和本地 `.rag/` 运行时目录,主要供 Agent 检索项目上下文,不作为人工阅读入口。默认不安装 RAG 相关依赖,不把 LanceDB、Transformers.js 或本地 embedding 模型写入根 `package.json`;需要启用时,Agent 必须先询问用户是否安装,并在用户确认后只安装到 gitignored 的 `.rag/runtime/`。索引范围默认包含 `AGENTS.md`、`CONTEXT.md`、`docs/project-memory/` 和 `docs/`,不把 `.codex/` 工具目录作为项目知识库索引源。 首次拉取或需要重建索引时: @@ -368,7 +368,7 @@ npm run check:spacetime-schema `rustfmt.toml` 固定 Edition 2024 的格式化口径。Rust 源码统一使用 `cargo fmt --all --manifest-path server-rs/Cargo.toml` 格式化,并用 `npm run check:rustfmt` 做只读校验;Codex 提交前门禁、API 生产构建和 -SpacetimeDB module 生产构建都会执行同一检查,避免不同开发机或构建节点反复产生格式差异。Web 生产构建还会执行 production-ops、ESLint、主站与后台类型检查,以及排除已下线旧玩法后的当前 Vitest;API 生产构建追加 production-ops、DDD/schema/runtime-access 和 api-server 全 target 编译检查;SpacetimeDB module 生产构建追加 production-ops、DDD/schema/runtime-access 和管理员 procedure smoke。上述门禁由 `npm run check:production-ops` 反查,不能只保留在本地说明中。 +SpacetimeDB module 生产构建都会执行同一检查,避免不同开发机或构建节点反复产生格式差异。Web 生产构建还会执行 production-ops、ESLint、主站与后台类型检查,以及排除已下线旧玩法后的当前 Vitest;API 生产构建追加 production-ops、DDD/schema/runtime-access 和 api-server 全 target 编译检查;SpacetimeDB module 生产构建追加 production-ops、DDD/schema/runtime-access 和管理员 procedure smoke。上述门禁由 `npm run check:production-ops` 反查,不能只保留在本地说明中。对需要跨格式保持稳定的脚本片段,门禁按去除空白后的源码片段匹配,避免仅因换行或格式化差异误报。 ## 前端改动验收 @@ -401,7 +401,9 @@ UI 相关修改要重点验证: ### SpacetimeDB 数据目录 OSS 备份 -数据库备份不放进 `spacetime-module` reducer / procedure:备份属于文件系统与 OSS 外部副作用,必须由运维脚本在 SpacetimeDB 宿主外执行。当前统一脚本为 `scripts/database-backup-to-oss.mjs`(npm 命令 `npm run database:backup:oss`)。默认 `--storage-format archive --mode full` 保持原有全量压缩包冷备行为;`--storage-format files` 不生成 tar.gz,而是把目录树映射成逐文件 CAS 对象与 catalog,full 重跑只上传新增或内容变化的文件,history 只处理已被最新 snapshot 完全覆盖的历史 commitlog 与旧 snapshot。`Genarrative-Server-Provision` 的 `DATABASE_BACKUP_PROFILE` 默认是 `archive-full`,继续安装每天 `03:20` 左右执行的全量冷备主 service;development 和 release 都可以显式选择 `files-history`,但指定 work-dir 必须已经有与本机 database/bucket 匹配且已发布的 full baseline state: +脚本停库前会在固定 work-dir 写入 `.spacetimedb-stopped` marker;正常 finally 恢复 SpacetimeDB 及 `--restart-service-after` 指定的 API / worker / controller 后才清理 marker。若 Node 因 `MemoryMax` / OOM 被强制终止,systemd `ExecStopPost` 会根据仍存在的 marker 兜底恢复这些服务;恢复未全部成功时保留 marker 供后续重试。 + +数据库备份不放进 `spacetime-module` reducer / procedure:备份属于文件系统与 OSS 外部副作用,必须由运维脚本在 SpacetimeDB 宿主外执行。当前统一脚本为 `scripts/database-backup-to-oss.mjs`(npm 命令 `npm run database:backup:oss`)。默认 `--storage-format archive --mode full` 保持原有全量压缩包冷备行为;`--storage-format files` 不生成 tar.gz,而是把目录树映射成逐文件 CAS 对象与 catalog,full 重跑只上传新增或内容变化的文件,history 只处理已被最新 snapshot 完全覆盖的历史 commitlog 与旧 snapshot。`Genarrative-Server-Provision` 的 `DATABASE_BACKUP_PROFILE` 默认是 `archive-full`,继续安装每天 `03:20` 左右执行的全量冷备主 service;当前 release 只允许 `archive-full`,避免 `files-history` 在大目录上构造全量 catalog 导致 Node 内存峰值;development 才可以显式选择 `files-history`,且指定 work-dir 必须已经有与本机 database/bucket 匹配且已发布的 full baseline state: ```bash npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service --restart-service-after genarrative-api.service --restart-service-after genarrative-external-generation-worker@1.service --restart-service-after genarrative-external-generation-controller.service @@ -437,7 +439,7 @@ GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET= `GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET` 为空时会回退 `ALIYUN_OSS_BUCKET`;AccessKey 默认复用 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET`,也可用 `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID` / `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET` 为备份 bucket 单独配置最小权限账号。冷备脚本会在停止 SpacetimeDB 前检查 `GENARRATIVE_DATABASE_BACKUP_WORK_DIR` 所在文件系统剩余空间;未设置 `GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES` 时,按数据目录大小加安全余量估算,空间不足会在停库前失败,避免写满根分区。即使打包或上传前步骤失败,只要脚本已经停过 SpacetimeDB,也会先恢复 SpacetimeDB 并执行 `--restart-service-after` 指定的 API / worker / controller,再带着原始备份错误退出。`Genarrative-Server-Provision` 会创建 `/var/lib/genarrative/database-backups` 并归属 `genarrative:genarrative`,同时安装并启用 `genarrative-database-backup.timer`。手动检查定时器:`systemctl list-timers genarrative-database-backup.timer`;手动触发一次:`systemctl start genarrative-database-backup.service`。如果 timer 显示 `enabled` 但 `inactive/dead` 且 `NEXT` / `Trigger` 为空,先写入当前 stamp 避免 `Persistent=true` 在白天立刻补跑冷备份:`touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer && systemctl daemon-reload && systemctl start genarrative-database-backup.timer`,随后确认下一次触发时间约为次日 `03:20`。 -`files-history` 使用仓库模板 `deploy/systemd/genarrative-database-backup-files-history.conf` 覆盖主 service 的 `ExecStart`,从 `/etc/genarrative/api-server.env` 读取 data-dir、database、bucket、prefix 与 OSS 凭据,不在 unit 写死环境目标,也不传 `--stop-service`。Server-Provision 在改动 drop-in 前,先用 current release 的同一脚本、同一 env 和 `DATABASE_BACKUP_FILES_HISTORY_WORK_DIR` 执行一次 history `--dry-run`;缺少已发布 full catalog 的 files state、current 脚本过旧或配置不匹配都会在安装 drop-in 和 `daemon-reload` 前失败。选择 `archive-full` 会主动删除仓库托管的 `10-files-history.conf` 与 dev 试点遗留的 `10-dev-files.conf`,防止 systemd 继续合并旧覆盖。dev 可继续指定已有 `/var/lib/genarrative/database-backups/dev-files`,release 建议先在 `/var/lib/genarrative/database-backups/release-files` 建立自己的 full baseline;两台机器不得复用或互传本地 state 目录冒充本机基线。启用时通过 Server-Provision Job 选择目标、`DATABASE_BACKUP_PROFILE=files-history` 和对应 work-dir,先保持 `DRY_RUN=true` 核对,再以同参数正式 provision。不要直接在 `/etc/systemd/system` 手写第二份 drop-in。 +`files-history` 使用仓库模板 `deploy/systemd/genarrative-database-backup-files-history.conf` 覆盖主 service 的 `ExecStart`,从 `/etc/genarrative/api-server.env` 读取 data-dir、database、bucket、prefix 与 OSS 凭据,不在 unit 写死环境目标,也不传 `--stop-service`。Server-Provision 在 development 改动 drop-in 前,先用 current release 的同一脚本、同一 env 和 `DATABASE_BACKUP_FILES_HISTORY_WORK_DIR` 执行一次 history `--dry-run`;缺少已发布 full catalog 的 files state、current 脚本过旧或配置不匹配都会在安装 drop-in 和 `daemon-reload` 前失败。选择 `archive-full` 会主动删除仓库托管的 `10-files-history.conf` 与 dev 试点遗留的 `10-dev-files.conf`,防止 systemd 继续合并旧覆盖。`genarrative-database-backup.service` 还通过 `NODE_OPTIONS=--max-old-space-size=768`、`MemoryHigh=768M`、`MemoryMax=1G` 和 `OOMPolicy=stop` 给备份进程设置独立护栏;release 若现场残留 files-history drop-in,必须先按 archive-full 重新 provision 并确认 drop-in 已删除,再恢复定时器。dev 可继续指定已有 `/var/lib/genarrative/database-backups/dev-files`;两台机器不得复用或互传本地 state 目录冒充本机基线。启用时通过 Server-Provision Job 选择目标、`DATABASE_BACKUP_PROFILE=files-history` 和对应 work-dir,先保持 `DRY_RUN=true` 核对,再以同参数正式 provision。不要直接在 `/etc/systemd/system` 手写第二份 drop-in。 files full 会递归扫描 data-dir,保留空目录、每个普通文件的相对路径,以及目标仍位于 data-dir 内部的相对符号链接;绝对链接或解析后越界的链接直接拒绝。文件按 SHA-256 上传到不可变对象 key,catalog 记录目录、路径、长度、SHA、对象 key 和相对链接目标,不写 staging 主机的绝对路径。相同 catalog 重跑不重复 PUT;新增或变化文件先 HEAD CAS 对象,存在且长度/SHA 元数据一致就复用,否则上传。16 MiB 及以下对象使用单次 PUT 后 HEAD 验真,大对象继续使用 multipart;对象操作默认以 16 路并行执行,可用 `GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY=1..64` 调整。需要给线上入口留带宽时设置 `GENARRATIVE_DATABASE_BACKUP_UPLOAD_MAX_BYTES_PER_SECOND=`,该共享限速器只包裹备份上传流,空值或 `0` 表示不限速,不修改主机全局 qdisc。并发、限速和单次 PUT 都不改变“全部对象、catalog 与 latest pointer 成功后才推进 state/清理”的顺序。full 基线必须来自停库后的 data-dir 或已通过恢复验证的冻结副本;源文件上传前后 stat 虽会复核,但在线扫描不能保证大量文件属于同一跨文件一致时点。catalog 验真后,脚本把最新 full/history 引用发布到固定 `//latest.json`,全新机器不需要本地 state 即可自动发现恢复入口。 @@ -478,7 +480,7 @@ node -- scripts/database-backup-to-oss.mjs \ dev 出口过慢时,可以把冻结基线经内网 rsync 到 release 独立 staging,再由 release 上传 dev bucket。staging 必须位于 `/var/lib/genarrative/dev-database-backup-staging/` 一类隔离目录,命令显式传 staging `--data-dir`、独立 `--work-dir`、dev `--bucket`,且不得传 `--stop-service`;禁止指向或修改 release `/stdb`。中转 key 只为本次传输临时授权,结束后从 dev 私钥和 release `authorized_keys` 同时移除。上传完成后把整个 files work-dir/state 回传 dev,history 才能延续同一 baseline catalog。 -完整恢复默认从 OSS 固定 `latest.json` 读取最新 full catalog:先创建 `directories`,再把每个 `files[].objectKey` 下载到 `/` 并逐项核对 `sizeBytes` / `sha256`;history catalog 用于证明已清理历史仍有 OSS 对象,不需要把已被 full baseline 覆盖的旧文件叠回当前恢复目录。本地 state 仍可作为兼容入口,并同时支持旧 v1 JSON 与 v2 gzip,但不再是异机恢复的前置条件。随后用隔离 data-dir 启动同版本 standalone,验证 `/v1/ping`、日志中的 snapshot restore / commitlog replay / module launch、代表性 SQL 和 reducer。dev 已完成这轮 OSS-only 异机恢复与重启演练;release 已使用独立 `/var/lib/genarrative/database-backups/release-files` full baseline 和 `files-history` profile,现场最终 `ExecStart`、timer 状态与最近备份结果仍须在变更时重新核对。 +完整恢复默认从 OSS 固定 `latest.json` 读取最新 full catalog:先创建 `directories`,再把每个 `files[].objectKey` 下载到 `/` 并逐项核对 `sizeBytes` / `sha256`;history catalog 用于证明已清理历史仍有 OSS 对象,不需要把已被 full baseline 覆盖的旧文件叠回当前恢复目录。本地 state 仍可作为兼容入口,并同时支持旧 v1 JSON 与 v2 gzip,但不再是异机恢复的前置条件。随后用隔离 data-dir 启动同版本 standalone,验证 `/v1/ping`、日志中的 snapshot restore / commitlog replay / module launch、代表性 SQL 和 reducer。dev 已完成这轮 OSS-only 异机恢复与重启演练;release 使用 archive-full 时,现场最终 `ExecStart`、timer 状态与最近备份结果仍须在变更时重新核对。 ```bash node -- scripts/database-backup-to-oss.mjs \ @@ -677,7 +679,7 @@ worker 被硬杀或断电后,lease 过期任务只有尚未耗尽 `max_attempt - `api-server` 正常运行时 `/healthz` 只返回进程存活状态,`/readyz` 会同时检查进程是否仍接收新流量和 SpacetimeDB 连接租约是否健康;收到 `SIGINT` / `SIGTERM` 后会先把 readiness 标记为不可用,再让 Axum 停止接新连接并等待已有 HTTP 请求排空。systemd 仍以 `KillSignal=SIGINT` 停服务,`TimeoutStopSec=90` 作为长请求排空上限。 - SpacetimeDB 健康检查默认使用 `GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS=2` 的短等待窗口,和业务 procedure 的 `GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS` 分开。`/readyz` 失败时 `details.spacetime.stage` 会标出当前卡住阶段:`pool_acquire`、`connect_build`、`connect_handshake`、`read_model_subscribe`、`procedure_result`、`reducer_result` 或 `read_cache`;`elapsedMs` / `timeoutMs` 用于确认是否命中健康检查窗口。业务请求日志也会写入 `operation_kind`、`operation_name`、`spacetime_stage` 和 `elapsed_ms`,后续 45 秒超时不再只靠 Nginx `request_time=45s` 推断。 - `genarrative-api.service` 设置 `LimitNOFILE=65535`、`TasksMax=2048`;上线后用 `systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax -p TimeoutStopUSec` 和 `cat /proc/$(pidof api-server)/limits` 核对。 -- Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转 SpacetimeDB / otelcol 工具包;Linux build 节点只负责从内网 Git 源准备 provision 脚本和配置并上传给目标 agent。`Prepare Provision Tools` 在目标 dev / release agent 工作区内先检查 `/usr/local/bin/otelcol-contrib` 与 `${SPACETIME_ROOT}/bin/current`:SpacetimeDB 必须同时匹配运行版本 `2.7.0` 和 hotfix3 commit `d220349a...` 才能复用,裸 tag `a08663c7...` 即使版本号相同也必须拒绝;只有缺失或版本 / commit 不匹配时才使用 `PROVISION_DOWNLOADS_DIR` 里的本地包或从配置的下载源准备官方 `v2.7.0-hotfix3` 资产。`SPACETIME_EXPECTED_COMMIT` 与下载根必须成对调整,安装结果也执行同一 commit 门禁。otelcol-contrib 当前锁定 `0.151.0`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。 +- Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转 SpacetimeDB / otelcol 工具包;Linux build 节点只负责从内网 Git 源准备 provision 脚本和配置并上传给目标 agent。`Prepare Provision Tools` 在目标 dev / release agent 工作区内先检查 `/usr/local/bin/otelcol-contrib` 与 `${SPACETIME_ROOT}/bin/current`:SpacetimeDB 必须同时匹配运行版本 `2.8.3` 和 commit `8e410d28...` 才能复用;只有缺失或版本 / commit 不匹配时才使用 `PROVISION_DOWNLOADS_DIR` 里的本地包或从配置的下载源准备官方 `v2.8.3` 资产。`SPACETIME_EXPECTED_COMMIT` 与下载根必须成对调整,安装结果也执行同一 commit 门禁。otelcol-contrib 当前锁定 `0.151.0`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。 - 除 `Genarrative-Server-Provision` 外,`Genarrative-Stdb-Module-Build`、`Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-*Deploy`、`Genarrative-Database-Import/Export`、`Genarrative-Full-Build-And-Deploy` 和 `Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,仍按各自 Jenkinsfile 的 checkout 口径执行。Server provision 不使用公网备用 Git 源,目标部署 agent 也不再需要访问源码 Git remote。 - `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。该服务必须存在系统用户 / 组 `otelcol`,并且 `/etc/otelcol/genarrative-debug.yaml` 已安装到目标机;若看到 `status=217/USER` 或 `Failed to determine user credentials`,优先检查 `getent passwd otelcol`,再补齐 `/etc/otelcol` 配置目录并重启服务。 - Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;通用 API 使用 `genarrative_api_rps`,后台 API 使用 `genarrative_admin_rps`。通用 `/api` location 保留 `client_max_body_size 64m` 作为编辑器图片、视频和文档请求的反代兜底,真实大小仍由路由与业务校验负责。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先核对 release 模板与实际媒体大小。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效。 @@ -777,7 +779,7 @@ node scripts/test-ve-llm.mjs ### 手机验证码短信 -手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成、哈希存储和校验,不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此 `api-server` 重启后,已发送但未校验的验证码会失效。 +手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成并哈希,短期验证码投影随 `auth_store_projection_meta` 同步到 SpacetimeDB 后由任一 API 节点恢复和校验;不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此只要 SpacetimeDB 正常,`api-server` 重启不会使已发送但未过期的验证码失效。 生产默认短信配置: @@ -853,7 +855,9 @@ GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456 GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS=5000 ``` -outbox 采用 NDJSON 文件保存原始事件。达到 `BATCH_SIZE` 时会立刻把当前 active 文件原子封存为 sealed 文件,并马上切到新的 active 继续写入;后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件。SpacetimeDB 批量 procedure 返回成功后删除 sealed 文件,失败则保留文件并重试。`MAX_BYTES` 是每个 outbox 实例的磁盘保护阈值,不是 flush 阈值;超过后低价值 route tracking 和 BgFilter provider 失败审计可以被丢弃并记录日志 / 指标,关键同步事件不进入该丢弃路径。api-server 使用配置目录本身,BgFilter worker 固定使用其 `bgfilter-worker/` 子目录,两个进程不得操作同一个 active 文件。sealed 文件若出现无法解析的坏行,会重命名为 `corrupt-*` 隔离并记录 `genarrative.tracking_outbox.files.corrupt` 指标,避免一个坏文件阻塞后续批量入库。进程收到退出信号后会在 `GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS` 窗口内封存各自 active 文件并尽力 flush sealed 文件,超时或 SpacetimeDB 暂不可用时保留本地文件给下次同角色启动继续投递。该机制对已 enqueue 记录提供至少一次投递语义,依赖 `tracking_event.event_id` 幂等跳过重复事件;BgFilter 尚未 enqueue 或因硬上限 / 保护阈值被丢弃的审计不在该保证内。 +outbox 采用 NDJSON 文件保存原始事件。达到 `BATCH_SIZE` 时会立刻把当前 active 文件原子封存为 sealed 文件,并马上切到新的 active 继续写入;后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。worker 启动时会先封存并 flush 已存在的 active / sealed 文件,恢复窗口内 SpacetimeDB 暂不可用则保留文件并按后续周期重试;`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件。SpacetimeDB 批量 procedure 返回成功后删除 sealed 文件,失败则保留文件并重试。`MAX_BYTES` 是每个 outbox 实例的磁盘保护阈值,不是 flush 阈值;超过后低价值 route tracking 和 BgFilter provider 失败审计可以被丢弃并记录日志 / 指标,关键同步事件不进入该丢弃路径。api-server 使用配置目录本身,BgFilter worker 固定使用其 `bgfilter-worker/` 子目录,两个进程不得操作同一个 active 文件。sealed 文件若出现无法解析的坏行,会重命名为 `corrupt-*` 隔离并记录 `genarrative.tracking_outbox.files.corrupt` 指标,避免一个坏文件阻塞后续批量入库。进程收到退出信号后会在 `GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS` 窗口内封存各自 active 文件并尽力 flush sealed 文件,超时或 SpacetimeDB 暂不可用时保留本地文件给下次同角色启动继续投递。该机制对已 enqueue 记录提供至少一次投递语义,依赖 `tracking_event.event_id` 幂等跳过重复事件;BgFilter 尚未 enqueue 或因硬上限 / 保护阈值被丢弃的审计不在该保证内。 + +钱包退款正式 pending 队列在 SpacetimeDB 的 `profile_wallet_refund_outbox` 表中,由每个 API 节点的 worker 共同处理;worker 启动即扫描库内 pending 行,成功在同一事务内写钱包账本并删除 outbox 行,失败按库内 `available_at` / `attempts` 重试。只有 SpacetimeDB 完全不可达时才写本机 `wallet-refund-outbox` emergency spool;如果进程在“临时文件写完但尚未改名”阶段崩溃,启动恢复会校验 `tmp-*` 内容并原子提升为按 ledger id 命名的 pending 文件,损坏或冲突文件移入 `corrupt-*` 隔离目录。达到 `MAX_BYTES` 时不再静默丢弃退款,而是写入同一持久目录下的 `refund-overflow-*` 溢出文件并继续重放;溢出文件不计入普通容量阈值,但必须接入容量告警和人工补偿预案,底层磁盘写入失败仍按关键退款告警处理。worker 连接失败、库内 retry、emergency spool 写入 / 容量失败和 `corrupt-*` 出现都必须接入告警;人工补偿先按 refund ledger id 对账 `profile_wallet_ledger`、`asset_operation_wallet_settlement` 与两类 outbox,再通过受控退款 procedure 幂等重放,禁止直接手写钱包表。该目录不能替代库内 outbox;发布和主机替换必须保留 `/var/lib/genarrative/wallet-refund-outbox` 并纳入节点恢复 / 备份演练。容器 loadtest / 预览环境必须分别为 `api-server` 与 `external-generation-worker` 挂载各自的 tracking 与 wallet refund 命名卷,不能让节点重建清空本机恢复队列。 release 机器如果日志每秒刷 `tracking outbox ... Permission denied (os error 13)`,先检查 `/etc/genarrative/api-server.env` 是否缺少 `GENARRATIVE_TRACKING_OUTBOX_DIR`。缺少时 `api-server` 会回退到本地开发默认相对路径 `server-rs/.data/tracking-outbox`,而 systemd 的工作目录是只读发布目录 `/opt/genarrative/releases/`,`genarrative` 用户无法在其中创建 `server-rs`。修复顺序: @@ -864,7 +868,9 @@ systemctl restart genarrative-api.service journalctl -u genarrative-api.service --since '30 seconds ago' --no-pager | grep -E 'tracking outbox|Permission denied|os error 13' ``` -`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许在 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)恢复;不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json`,`module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 不再作为备查或运行期恢复源,只在正式认证表为空时一次性转移最新旧快照并清空,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。如果 `api-server` 启动时连不上 SpacetimeDB,会持续重试启动恢复,直到认证工作集从 SpacetimeDB 正式表恢复成功后才开始监听 HTTP,以避免用空本地状态或旧快照覆盖认证表。 +`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)和 `auth_store_projection_meta` 中的短期验证码 / 微信 state 投影恢复;所有会读取或变更本机认证工作集的认证主链路在领域操作前都会从正式投影做一次只读刷新,刷新失败即 fail closed,不依赖粘性会话。不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json`,`module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 不再作为备查或运行期恢复源,只在正式认证表为空时一次性转移最新旧快照并清空,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。所有 API 节点必须使用相同的 `GENARRATIVE_JWT_SECRET`,它也作为验证码哈希盐;轮换后尚未消费的验证码会失效。如果 `api-server` 启动时连不上 SpacetimeDB,会持续重试启动恢复,直到认证工作集从 SpacetimeDB 正式表和短期投影恢复成功后才开始监听 HTTP,以避免用空本地状态或旧快照覆盖认证表。 + +发短信运维门禁:handler 会先刷新正式认证投影,再通过 projection CAS 写入不可消费的占位验证码来占用跨节点冷却窗口;占用失败时不得调用短信 provider。微信 OAuth state 活动数量有上限,命中上限应返回服务错误并触发限流 / 入口告警;不要通过调大单个 `auth_store_projection_meta` JSON 字段来绕过该保护。 前端登录态恢复只把 `/api/auth/refresh` 的 `401` / `403` 当成权威失效信号;服务器重启窗口里的 `502` / `503` / `504`、浏览器 `Failed to fetch` 或 refresh 响应契约异常都必须保留已有本地 access token,不触发全局 auth 变化。refresh 成功响应以共享契约 `RefreshSessionResponse { token }` 为准,前端不要额外要求业务 `ok` 字段。排查“重启后用户都掉线”时,先区分前端是否被暂时不可用清掉本地 token,再检查 SpacetimeDB 正式认证表是否缺 `user_account` / `refresh_session` 数据。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 38f4e5a2d..68e502148 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -119,7 +119,7 @@ server-rs + Axum + SpacetimeDB - Issue tracker 是自托管 Gitea。可用 Gitea UI/API 或 `tea` CLI;不要用 GitHub `gh` 或 GitLab `glab`。 - 默认 triage labels:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。 - 根 `CONTEXT.md` 是当前领域语言入口;架构决策以本文档和 `docs/project-memory/shared-memory/decision-log.md` 的最新稳定摘要为准。 -- `.hermes/` 只保存 Hermes 专用的仓库级工具资源,例如 skills、plugins 和启用说明;团队共享记忆、计划和 TODO 统一放在 `docs/project-memory/`,不提交个人 Hermes 配置、会话、密钥、Token 或本地私密路径。 +- `.codex/` 只保存仓库级 Codex 工具资源,例如 skills、plugins、hooks 和配置模板;团队共享记忆、计划和 TODO 统一放在 `docs/project-memory/`,不提交个人 Codex 配置、会话、密钥、Token 或本地私密路径。 - 每次工程修改都应同步更新本目录当前文档;如果产生长期有效知识,再同步 `docs/project-memory/shared-memory/`。 ## 当前文档策略 diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index 69ea81c22..d2104e68c 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -25,8 +25,8 @@ pipeline { string(name: 'PROVISION_DOWNLOADS_DIR', defaultValue: 'provision-tool-downloads', description: '目标服务器工作区内暂存 SpacetimeDB/otelcol 安装包的相对目录') string(name: 'PROVISION_TOOLS_DIR', defaultValue: 'provision-tools', description: '目标机工作区内由已下载安装包生成的工具包目录') string(name: 'PROVISION_DOWNLOAD_PROXY', defaultValue: '', description: '可选,目标服务器下载 SpacetimeDB 和 otelcol-contrib 时使用的代理地址,例如 http://127.0.0.1:7890;留空不设置代理') - string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.7.0-hotfix3', description: '目标服务器使用的 SpacetimeDB Linux release tarball 根地址;默认固定到项目锁定版本的官方 hotfix 资产标签') - string(name: 'SPACETIME_EXPECTED_COMMIT', defaultValue: 'd220349adb7af7eefa810eb08a185609356b83f6', description: 'SpacetimeDB CLI 预期构建 commit;用于拒绝同版本号但缺少 hotfix 的旧二进制') + string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.8.3', description: '目标服务器使用的 SpacetimeDB Linux release tarball 根地址;默认固定到项目锁定版本的官方资产标签') + string(name: 'SPACETIME_EXPECTED_COMMIT', defaultValue: '8e410d2842147bd8e5a32a9589cc00c19f7478e2', description: 'SpacetimeDB CLI 预期构建 commit;用于拒绝同版本号但 commit 不匹配的旧二进制') string(name: 'SPACETIME_TARGET_HOST', defaultValue: 'x86_64-unknown-linux-gnu', description: 'SpacetimeDB 预编译包 host triple,development/release Linux amd64 使用默认值') string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir') string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录') @@ -34,8 +34,8 @@ pipeline { string(name: 'WEB_LINK', defaultValue: '/srv/genarrative/web', description: 'Nginx 静态站点目录或软链接') string(name: 'API_ENV_FILE', defaultValue: '/etc/genarrative/api-server.env', description: 'api-server 环境文件') string(name: 'API_PORT', defaultValue: '8082', description: 'api-server 本机监听端口') - choice(name: 'DATABASE_BACKUP_PROFILE', choices: ['archive-full', 'files-history'], description: '数据库定时备份 profile;默认 archive-full,files-history 仅在指定 work-dir 已有完整 full baseline 后启用') - string(name: 'DATABASE_BACKUP_FILES_HISTORY_WORK_DIR', defaultValue: '/var/lib/genarrative/database-backups/files-history', description: 'files-history 的本地 state/catalog 目录;dev/release 必须使用各自已建立 full baseline 的独立目录') + choice(name: 'DATABASE_BACKUP_PROFILE', choices: ['archive-full', 'files-history'], description: '数据库定时备份 profile;release 仅允许 archive-full,files-history 仅供 development 在指定 work-dir 已有完整 full baseline 后启用') + string(name: 'DATABASE_BACKUP_FILES_HISTORY_WORK_DIR', defaultValue: '/var/lib/genarrative/database-backups/files-history', description: 'development files-history 的本地 state/catalog 目录;必须使用已建立 full baseline 的独立目录') choice(name: 'NGINX_CONFIG_MODE', choices: ['none', 'production-https', 'development-http'], description: 'Nginx 配置模式;开发服无域名时选 development-http,release 正式入口选 production-https') booleanParam(name: 'ENABLE_SERVICES', defaultValue: true, description: '启用并启动 spacetimedb 与 api-server systemd 服务') booleanParam(name: 'ENABLE_OTELCOL', defaultValue: true, description: '安装并启用本机 OpenTelemetry Collector;api-server 模板默认开启 OTLP,如需关闭请在 API_ENV_FILE 中将 GENARRATIVE_OTEL_ENABLED 改为 false') @@ -113,6 +113,9 @@ pipeline { if (!(databaseBackupProfile in ['archive-full', 'files-history'])) { error("DATABASE_BACKUP_PROFILE 只能是 archive-full 或 files-history,当前值: ${params.DATABASE_BACKUP_PROFILE}") } + if (params.DEPLOY_TARGET == 'release' && databaseBackupProfile == 'files-history') { + error('release 仅允许 archive-full;files-history 会把整棵历史目录加载到 Node 内存,需先完成流式 catalog 改造后才能重新启用。') + } def databaseBackupFilesHistoryWorkDir = params.DATABASE_BACKUP_FILES_HISTORY_WORK_DIR?.trim() if (!(databaseBackupFilesHistoryWorkDir ==~ /^\/var\/lib\/genarrative\/database-backups\/[A-Za-z0-9._\/-]+$/) || databaseBackupFilesHistoryWorkDir.contains('..')) { error("DATABASE_BACKUP_FILES_HISTORY_WORK_DIR 必须是 /var/lib/genarrative/database-backups/ 下不含连续点号的绝对路径,当前值: ${params.DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}") @@ -213,8 +216,8 @@ BASH OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" \ PREPARE_OTELCOL="${ENABLE_OTELCOL:-true}" \ PROVISION_DOWNLOAD_PROXY="${PROVISION_DOWNLOAD_PROXY:-}" \ - SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.7.0-hotfix3}" \ - SPACETIME_EXPECTED_COMMIT="${SPACETIME_EXPECTED_COMMIT:-d220349adb7af7eefa810eb08a185609356b83f6}" \ + SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.8.3}" \ + SPACETIME_EXPECTED_COMMIT="${SPACETIME_EXPECTED_COMMIT:-8e410d2842147bd8e5a32a9589cc00c19f7478e2}" \ SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" \ SPACETIME_ROOT="${SPACETIME_ROOT:-/stdb}" \ scripts/prepare-server-provision-tools.sh diff --git a/scripts/check-admin-account-procedures.mjs b/scripts/check-admin-account-procedures.mjs index f0b686ee0..4880f09f6 100644 --- a/scripts/check-admin-account-procedures.mjs +++ b/scripts/check-admin-account-procedures.mjs @@ -20,7 +20,7 @@ const repoRoot = path.resolve( '..', ); const database = 'admin-account-smoke'; -const expectedSpacetimeVersion = '2.7.0'; +const expectedSpacetimeVersion = '2.8.3'; const commandTimeoutMs = 5 * 60 * 1000; function assert(condition, message) { @@ -144,6 +144,10 @@ async function stopStandalone(child) { if (child.exitCode !== null) { return; } + if (process.platform === 'win32') { + await stopWindowsProcessTree(child); + return; + } child.kill('SIGTERM'); const exited = await Promise.race([ once(child, 'exit').then(() => true), @@ -155,6 +159,37 @@ async function stopStandalone(child) { } } +async function stopWindowsProcessTree(child) { + if (typeof child.pid === 'number') { + await runTaskKill(child.pid); + } + const exited = await Promise.race([ + once(child, 'exit').then(() => true), + delay(5_000).then(() => false), + ]); + if (!exited && child.exitCode === null) { + child.kill('SIGKILL'); + await once(child, 'exit'); + } +} + +function runTaskKill(pid) { + return new Promise((resolve, reject) => { + const taskKill = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { + stdio: 'ignore', + shell: false, + }); + taskKill.once('error', reject); + taskKill.once('exit', (code, signal) => { + if (code === 0 || code === 128 || code === 1) { + resolve(); + return; + } + reject(new Error(`taskkill exited with ${signal ?? code}`)); + }); + }); +} + async function callProcedure(serverUrl, token, procedureName, input) { const response = await fetch( `${serverUrl}/v1/database/${database}/call/${procedureName}`, diff --git a/scripts/check-database-backup-to-oss.mjs b/scripts/check-database-backup-to-oss.mjs index 57bc09368..b42f5dbfc 100644 --- a/scripts/check-database-backup-to-oss.mjs +++ b/scripts/check-database-backup-to-oss.mjs @@ -1,12 +1,24 @@ #!/usr/bin/env node -import {spawnSync} from 'node:child_process'; -import {createHash} from 'node:crypto'; -import {chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync} from 'node:fs'; -import {tmpdir} from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; import path from 'node:path'; -import {Readable} from 'node:stream'; -import {gunzipSync, gzipSync} from 'node:zlib'; +import { Readable } from 'node:stream'; +import { gunzipSync, gzipSync } from 'node:zlib'; import { buildAuthorization, @@ -27,13 +39,15 @@ import { } from './database-backup-to-oss.mjs'; const BACKUP_SCRIPT = path.resolve('scripts/database-backup-to-oss.mjs'); -const tmpRoot = mkdtempSync(path.join(tmpdir(), 'genarrative-database-backup-check-')); +const tmpRoot = mkdtempSync( + path.join(tmpdir(), 'genarrative-database-backup-check-'), +); const failures = []; try { await main(); } finally { - rmSync(tmpRoot, {recursive: true, force: true}); + rmSync(tmpRoot, { recursive: true, force: true }); } if (failures.length > 0) { @@ -50,6 +64,7 @@ async function main() { assertDeferredArchiveDiscoveryIsBoundedAndDeterministic(); assertCanonicalQueryAndAuthorizationIncludeMultipartParameters(); assertInsufficientSpaceStopsBeforeServiceChanges(); + assertStopFailureRetainsRecoveryMarker(); assertArchiveFailureStillRestoresDependentServices(); await assertMultipartUploadRetriesAndVerifiesRemoteLength(); await assertUploadBandwidthLimiterSharesBudgetAndPropagatesErrors(); @@ -77,80 +92,149 @@ async function main() { function assertDeferredArchiveDiscoveryIsBoundedAndDeterministic() { const root = path.join(tmpRoot, 'deferred-archive-discovery'); - mkdirSync(root, {recursive: true}); - const createCandidate = ({name, status, database = 'test-db', withArchive = true}) => { + mkdirSync(root, { recursive: true }); + const createCandidate = ({ + name, + status, + database = 'test-db', + withArchive = true, + }) => { const archivePath = path.join(root, `${name}.tar.gz`); const manifestPath = `${archivePath}.manifest.json`; if (withArchive) { writeFileSync(archivePath, name); } - writeFileSync(manifestPath, `${JSON.stringify({ - backupKind: 'spacetimedb-data-dir', - database, - archivePath, - uploadStatus: status, - })}\n`); - return {archivePath, manifestPath}; + writeFileSync( + manifestPath, + `${JSON.stringify({ + backupKind: 'spacetimedb-data-dir', + database, + archivePath, + uploadStatus: status, + })}\n`, + ); + return { archivePath, manifestPath }; }; - const later = createCandidate({name: 'test-db-20260731T020000Z', status: 'pending'}); - const earlier = createCandidate({name: 'test-db-20260731T010000Z', status: 'deferred'}); - const uploaded = createCandidate({name: 'test-db-20260731T000000Z', status: 'uploaded'}); - createCandidate({name: 'other-db-20260731T000000Z', status: 'deferred', database: 'other-db'}); - const missing = createCandidate({name: 'test-db-20260730T230000Z', status: 'deferred', withArchive: false}); + const later = createCandidate({ + name: 'test-db-20260731T020000Z', + status: 'pending', + }); + const earlier = createCandidate({ + name: 'test-db-20260731T010000Z', + status: 'deferred', + }); + const uploaded = createCandidate({ + name: 'test-db-20260731T000000Z', + status: 'uploaded', + }); + createCandidate({ + name: 'other-db-20260731T000000Z', + status: 'deferred', + database: 'other-db', + }); + const missing = createCandidate({ + name: 'test-db-20260730T230000Z', + status: 'deferred', + withArchive: false, + }); - const result = discoverDeferredArchiveUploads({workDir: root, database: 'test-db'}); + const result = discoverDeferredArchiveUploads({ + workDir: root, + database: 'test-db', + }); assertEqual( - result.archives.map(({archivePath}) => archivePath).join(','), + result.archives.map(({ archivePath }) => archivePath).join(','), [earlier.archivePath, later.archivePath].join(','), 'deferred/pending 扫描必须只返回同库现存归档,并按文件名稳定排序。', ); - assertEqual(result.missingArchives.length, 1, '缺失归档的 deferred 清单必须单独报告。'); - assertEqual(result.missingArchives[0].manifestPath, missing.manifestPath, '缺失归档报告必须保留精确 manifest。'); - const cleanupResult = discoverDeferredArchiveUploads({workDir: root, database: 'test-db', includeUploaded: true}); assertEqual( - cleanupResult.archives.map(({archivePath}) => archivePath).join(','), + result.missingArchives.length, + 1, + '缺失归档的 deferred 清单必须单独报告。', + ); + assertEqual( + result.missingArchives[0].manifestPath, + missing.manifestPath, + '缺失归档报告必须保留精确 manifest。', + ); + const cleanupResult = discoverDeferredArchiveUploads({ + workDir: root, + database: 'test-db', + includeUploaded: true, + }); + assertEqual( + cleanupResult.archives.map(({ archivePath }) => archivePath).join(','), [uploaded.archivePath, earlier.archivePath, later.archivePath].join(','), '未要求保留本地归档时,补偿扫描必须同时收敛上传后未清理的本地归档。', ); - const cliDryRun = spawnSync(process.execPath, [ - BACKUP_SCRIPT, - '--upload-deferred-dir', root, - '--database', 'test-db', - '--bucket', 'test-bucket', - '--endpoint', 'oss-cn-shanghai.aliyuncs.com', - '--access-key-id', 'test-id', - '--access-key-secret', 'test-secret', - '--keep-local', - '--dry-run', - ], {encoding: 'utf8'}); - assertStatus(cliDryRun, 0, 'deferred 补偿扫描 dry-run 必须可通过统一 CLI 入口执行。'); - assertIncludes(cliDryRun.stdout, 'count=2', 'deferred 补偿扫描 CLI 必须报告待处理归档数量。'); - assertTrue(existsSync(earlier.archivePath) && existsSync(later.archivePath), 'dry-run 不得删除 deferred 本地归档。'); + const cliDryRun = spawnSync( + process.execPath, + [ + BACKUP_SCRIPT, + '--upload-deferred-dir', + root, + '--database', + 'test-db', + '--bucket', + 'test-bucket', + '--endpoint', + 'oss-cn-shanghai.aliyuncs.com', + '--access-key-id', + 'test-id', + '--access-key-secret', + 'test-secret', + '--keep-local', + '--dry-run', + ], + { encoding: 'utf8' }, + ); + assertStatus( + cliDryRun, + 0, + 'deferred 补偿扫描 dry-run 必须可通过统一 CLI 入口执行。', + ); + assertIncludes( + cliDryRun.stdout, + 'count=2', + 'deferred 补偿扫描 CLI 必须报告待处理归档数量。', + ); + assertTrue( + existsSync(earlier.archivePath) && existsSync(later.archivePath), + 'dry-run 不得删除 deferred 本地归档。', + ); const unsafeRoot = path.join(tmpRoot, 'deferred-archive-unsafe'); - mkdirSync(unsafeRoot, {recursive: true}); + mkdirSync(unsafeRoot, { recursive: true }); const escapedArchive = path.join(tmpRoot, 'outside.tar.gz'); writeFileSync(escapedArchive, 'outside'); writeFileSync( path.join(unsafeRoot, 'test-db-unsafe.tar.gz.manifest.json'), - `${JSON.stringify({database: 'test-db', archivePath: escapedArchive, uploadStatus: 'deferred'})}\n`, + `${JSON.stringify({ database: 'test-db', archivePath: escapedArchive, uploadStatus: 'deferred' })}\n`, ); assertThrows( - () => discoverDeferredArchiveUploads({workDir: unsafeRoot, database: 'test-db'}), + () => + discoverDeferredArchiveUploads({ + workDir: unsafeRoot, + database: 'test-db', + }), '路径与清单不匹配', 'deferred 扫描必须拒绝目录外归档或 manifest 名不匹配。', ); const symlinkRoot = path.join(tmpRoot, 'deferred-archive-symlink'); - mkdirSync(symlinkRoot, {recursive: true}); + mkdirSync(symlinkRoot, { recursive: true }); const symlinkArchive = path.join(symlinkRoot, 'test-db-symlink.tar.gz'); symlinkSync(escapedArchive, symlinkArchive); writeFileSync( `${symlinkArchive}.manifest.json`, - `${JSON.stringify({database: 'test-db', archivePath: symlinkArchive, uploadStatus: 'deferred'})}\n`, + `${JSON.stringify({ database: 'test-db', archivePath: symlinkArchive, uploadStatus: 'deferred' })}\n`, ); assertThrows( - () => discoverDeferredArchiveUploads({workDir: symlinkRoot, database: 'test-db'}), + () => + discoverDeferredArchiveUploads({ + workDir: symlinkRoot, + database: 'test-db', + }), '非符号链接的普通文件', 'deferred 扫描必须拒绝符号链接归档。', ); @@ -164,22 +248,36 @@ function createDirectOssHarness() { const objects = new Map(); const uploadedKeys = []; const verifiedKeys = []; - const uploadFn = async ({archivePath, objectKey, archiveSha256}) => { + const uploadFn = async ({ archivePath, objectKey, archiveSha256 }) => { const body = readFileSync(archivePath); const sha256 = createHash('sha256').update(body).digest('hex'); - assertEqual(sha256, archiveSha256, `direct file ${objectKey} 的上传 SHA 必须来自实际内容。`); - objects.set(objectKey, {body, contentLength: body.length, sha256}); + assertEqual( + sha256, + archiveSha256, + `direct file ${objectKey} 的上传 SHA 必须来自实际内容。`, + ); + objects.set(objectKey, { body, contentLength: body.length, sha256 }); uploadedKeys.push(objectKey); - return {objectKey, contentLength: body.length, archiveSha256: sha256, verifiedAt: '2026-07-16T01:00:00.000Z'}; + return { + objectKey, + contentLength: body.length, + archiveSha256: sha256, + verifiedAt: '2026-07-16T01:00:00.000Z', + }; }; - const uploadManifestFn = async ({manifestPath, objectKey}) => { + const uploadManifestFn = async ({ manifestPath, objectKey }) => { const body = readFileSync(manifestPath); const sha256 = createHash('sha256').update(body).digest('hex'); - objects.set(objectKey, {body, contentLength: body.length, sha256}); + objects.set(objectKey, { body, contentLength: body.length, sha256 }); uploadedKeys.push(objectKey); - return {objectKey, contentLength: body.length, archiveSha256: sha256, verifiedAt: '2026-07-16T01:00:01.000Z'}; + return { + objectKey, + contentLength: body.length, + archiveSha256: sha256, + verifiedAt: '2026-07-16T01:00:01.000Z', + }; }; - const verifyFn = async ({objectKey, contentLength, archiveSha256}) => { + const verifyFn = async ({ objectKey, contentLength, archiveSha256 }) => { verifiedKeys.push(objectKey); const object = objects.get(objectKey); if (!object) { @@ -187,12 +285,22 @@ function createDirectOssHarness() { error.status = 404; throw error; } - if (object.contentLength !== contentLength || object.sha256 !== archiveSha256) { + if ( + object.contentLength !== contentLength || + object.sha256 !== archiveSha256 + ) { throw new Error(`mismatch ${objectKey}`); } - return {verifiedAt: '2026-07-16T01:00:02.000Z'}; + return { verifiedAt: '2026-07-16T01:00:02.000Z' }; + }; + return { + objects, + uploadedKeys, + verifiedKeys, + uploadFn, + uploadManifestFn, + verifyFn, }; - return {objects, uploadedKeys, verifiedKeys, uploadFn, uploadManifestFn, verifyFn}; } async function assertDirectSmallFileUsesSinglePut() { @@ -208,13 +316,19 @@ async function assertDirectSmallFileUsesSinglePut() { for await (const chunk of options.body) { uploadedBytes += chunk.length; } - return new Response('', {status: 200, headers: {etag: '"single-etag"'}}); + return new Response('', { + status: 200, + headers: { etag: '"single-etag"' }, + }); } if (options.method === 'HEAD') { - return new Response(null, {status: 200, headers: { - 'content-length': String(body.length), - 'x-oss-meta-file-sha256': sha256, - }}); + return new Response(null, { + status: 200, + headers: { + 'content-length': String(body.length), + 'x-oss-meta-file-sha256': sha256, + }, + }); } throw new Error(`unexpected method ${options.method}`); }; @@ -230,12 +344,20 @@ async function assertDirectSmallFileUsesSinglePut() { bandwidthLimiter: createUploadBandwidthLimiter(64 * 1024), }); assertEqual(result.uploadMode, 'single', '小型逐文件对象必须使用单次 PUT。'); - assertEqual(methods.join(','), 'PUT,HEAD', '小型逐文件对象只能执行 PUT 后 HEAD 验真,不得进入 multipart。'); + assertEqual( + methods.join(','), + 'PUT,HEAD', + '小型逐文件对象只能执行 PUT 后 HEAD 验真,不得进入 multipart。', + ); assertEqual(uploadedBytes, body.length, '逐文件带宽限制流不得丢失上传内容。'); } async function assertUploadBandwidthLimiterSharesBudgetAndPropagatesErrors() { - assertEqual(createUploadBandwidthLimiter('0'), null, '上传带宽限制为 0 时必须关闭。'); + assertEqual( + createUploadBandwidthLimiter('0'), + null, + '上传带宽限制为 0 时必须关闭。', + ); assertThrows( () => createUploadBandwidthLimiter('1023'), '必须为空、0 或 >= 1024 的整数', @@ -256,120 +378,265 @@ async function assertUploadBandwidthLimiterSharesBudgetAndPropagatesErrors() { return totalBytes; }; const [firstBytes, secondBytes] = await Promise.all([ - consume(limiter.wrap(Readable.from([Buffer.alloc(1024), Buffer.alloc(1024)], {objectMode: false}))), - consume(limiter.wrap(Readable.from([Buffer.alloc(1024), Buffer.alloc(1024)], {objectMode: false}))), + consume( + limiter.wrap( + Readable.from([Buffer.alloc(1024), Buffer.alloc(1024)], { + objectMode: false, + }), + ), + ), + consume( + limiter.wrap( + Readable.from([Buffer.alloc(1024), Buffer.alloc(1024)], { + objectMode: false, + }), + ), + ), ]); - assertEqual(firstBytes + secondBytes, 4096, '共享上传限速器不得丢失并发流内容。'); - assertEqual(delays.join(','), '1000,2000,3000,4000', '两个并发上传流必须共享同一个累计带宽预算。'); + assertEqual( + firstBytes + secondBytes, + 4096, + '共享上传限速器不得丢失并发流内容。', + ); + assertEqual( + delays.join(','), + '1000,2000,3000,4000', + '两个并发上传流必须共享同一个累计带宽预算。', + ); let sourceError = null; try { - await consume(limiter.wrap(Readable.from((async function* failingSource() { - yield Buffer.alloc(1); - throw new Error('source-read-failed'); - })(), {objectMode: false}))); + await consume( + limiter.wrap( + Readable.from( + (async function* failingSource() { + yield Buffer.alloc(1); + throw new Error('source-read-failed'); + })(), + { objectMode: false }, + ), + ), + ); } catch (error) { sourceError = error; } - assertIncludes(sourceError?.message, 'source-read-failed', '限速流必须向上传请求透传源读取错误。'); + assertIncludes( + sourceError?.message, + 'source-read-failed', + '限速流必须向上传请求透传源读取错误。', + ); } async function assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload() { const root = path.join(tmpRoot, 'direct-files-incremental'); const dataDir = path.join(root, 'stdb'); const workDir = path.join(root, 'work'); - mkdirSync(path.join(dataDir, 'replicas', '1', 'snapshots', '00000000000000000010.snapshot_dir', 'objects'), {recursive: true}); - mkdirSync(path.join(dataDir, 'empty-directory'), {recursive: true}); - mkdirSync(path.join(dataDir, 'bin', '2.6.0'), {recursive: true}); + mkdirSync( + path.join( + dataDir, + 'replicas', + '1', + 'snapshots', + '00000000000000000010.snapshot_dir', + 'objects', + ), + { recursive: true }, + ); + mkdirSync(path.join(dataDir, 'empty-directory'), { recursive: true }); + mkdirSync(path.join(dataDir, 'bin', '2.6.0'), { recursive: true }); symlinkSync('2.6.0', path.join(dataDir, 'bin', 'current')); writeFileSync(path.join(dataDir, 'control-db'), 'control'); writeFileSync( - path.join(dataDir, 'replicas', '1', 'snapshots', '00000000000000000010.snapshot_dir', 'objects', 'object.bin'), + path.join( + dataDir, + 'replicas', + '1', + 'snapshots', + '00000000000000000010.snapshot_dir', + 'objects', + 'object.bin', + ), 'snapshot object', ); const harness = createDirectOssHarness(); const options = { - mode: 'full', dataDir, workDir, database: 'test-db', bucket: 'backup-bucket', objectPrefix: 'database-backups', - uploadOptions: {}, uploadFn: harness.uploadFn, uploadManifestFn: harness.uploadManifestFn, verifyFn: harness.verifyFn, + mode: 'full', + dataDir, + workDir, + database: 'test-db', + bucket: 'backup-bucket', + objectPrefix: 'database-backups', + uploadOptions: {}, + uploadFn: harness.uploadFn, + uploadManifestFn: harness.uploadManifestFn, + verifyFn: harness.verifyFn, }; - const collected = await collectDirectFileEntries({dataDir, database: 'test-db', objectPrefix: 'database-backups'}); + const collected = await collectDirectFileEntries({ + dataDir, + database: 'test-db', + objectPrefix: 'database-backups', + }); assertTrue( - collected.files.some(({path: filePath}) => filePath === 'replicas/1/snapshots/00000000000000000010.snapshot_dir/objects/object.bin'), + collected.files.some( + ({ path: filePath }) => + filePath === + 'replicas/1/snapshots/00000000000000000010.snapshot_dir/objects/object.bin', + ), 'files catalog 必须原样保留 snapshot 内文件的相对路径。', ); - assertTrue(collected.directories.includes('empty-directory'), 'files catalog 必须保留空目录。'); assertTrue( - collected.symlinks.some(({path: symlinkPath, target}) => symlinkPath === 'bin/current' && target === '2.6.0'), + collected.directories.includes('empty-directory'), + 'files catalog 必须保留空目录。', + ); + assertTrue( + collected.symlinks.some( + ({ path: symlinkPath, target }) => + symlinkPath === 'bin/current' && target === '2.6.0', + ), 'files catalog 必须保留指向 data-dir 内部的相对符号链接。', ); const first = await runDirectFilesBackup(options); assertEqual(first.uploadedCount, 2, '首次 files full 应上传全部普通文件。'); - assertTrue(!Object.hasOwn(first.catalog, 'dataDir'), '远端 files catalog 不得绑定 staging 主机的绝对 data-dir。'); - assertTrue(first.statePath.endsWith('.json.gz'), 'files state 必须使用 gzip 压缩文件。'); + assertTrue( + !Object.hasOwn(first.catalog, 'dataDir'), + '远端 files catalog 不得绑定 staging 主机的绝对 data-dir。', + ); + assertTrue( + first.statePath.endsWith('.json.gz'), + 'files state 必须使用 gzip 压缩文件。', + ); const compactState = readGzipJson(first.statePath); - assertEqual(compactState.schemaVersion, 2, 'files state 必须使用去重后的 v2 契约。'); - assertTrue(!Object.hasOwn(compactState.baselineCatalog, 'files'), 'baseline ref 不得重复嵌入 files。'); - assertTrue(!Object.hasOwn(compactState.latestCatalog, 'files'), 'latest ref 不得重复嵌入 files。'); - assertTrue(!existsSync(first.catalogPath), '本地 full catalog 原始 JSON 应在成功后压缩。'); - assertTrue(existsSync(`${first.catalogPath}.gz`), '本地应保留压缩后的 latest full catalog 供增量复用。'); + assertEqual( + compactState.schemaVersion, + 2, + 'files state 必须使用去重后的 v2 契约。', + ); + assertTrue( + !Object.hasOwn(compactState.baselineCatalog, 'files'), + 'baseline ref 不得重复嵌入 files。', + ); + assertTrue( + !Object.hasOwn(compactState.latestCatalog, 'files'), + 'latest ref 不得重复嵌入 files。', + ); + assertTrue( + !existsSync(first.catalogPath), + '本地 full catalog 原始 JSON 应在成功后压缩。', + ); + assertTrue( + existsSync(`${first.catalogPath}.gz`), + '本地应保留压缩后的 latest full catalog 供增量复用。', + ); const latestObjectKey = 'database-backups/test-db/latest.json'; - const latest = JSON.parse(harness.objects.get(latestObjectKey).body.toString('utf8')); - assertEqual(latest.latestFullCatalog.catalogId, first.catalogId, 'latest pointer 必须指向已验真的最新 full catalog。'); - assertTrue(!Object.hasOwn(latest.latestFullCatalog, 'files'), 'latest full ref 不得嵌入 files 数组。'); - assertTrue(latest.historyCatalogs.every((catalog) => !Object.hasOwn(catalog, 'files')), 'latest history ref 不得嵌入 files 数组。'); - const immutableUploadsAfterFirst = harness.uploadedKeys.filter((objectKey) => objectKey !== latestObjectKey).length; + const latest = JSON.parse( + harness.objects.get(latestObjectKey).body.toString('utf8'), + ); + assertEqual( + latest.latestFullCatalog.catalogId, + first.catalogId, + 'latest pointer 必须指向已验真的最新 full catalog。', + ); + assertTrue( + !Object.hasOwn(latest.latestFullCatalog, 'files'), + 'latest full ref 不得嵌入 files 数组。', + ); + assertTrue( + latest.historyCatalogs.every((catalog) => !Object.hasOwn(catalog, 'files')), + 'latest history ref 不得嵌入 files 数组。', + ); + const immutableUploadsAfterFirst = harness.uploadedKeys.filter( + (objectKey) => objectKey !== latestObjectKey, + ).length; const repeated = await runDirectFilesBackup(options); assertEqual(repeated.uploadedCount, 0, '相同目录重复运行不得重复上传文件。'); assertEqual( - harness.uploadedKeys.filter((objectKey) => objectKey !== latestObjectKey).length, + harness.uploadedKeys.filter((objectKey) => objectKey !== latestObjectKey) + .length, immutableUploadsAfterFirst, '相同 catalog 重跑不得重复 PUT 文件或 catalog,但应覆盖验真 latest pointer。', ); - rmSync(`${first.catalogPath}.gz`, {force: false}); + rmSync(`${first.catalogPath}.gz`, { force: false }); writeFileSync(path.join(dataDir, 'control-db'), 'control changed'); writeFileSync(path.join(dataDir, 'new-program.bin'), 'new program'); const incremental = await runDirectFilesBackup(options); - assertEqual(incremental.uploadedCount, 2, '增量 files full 只应上传新增和变化文件。'); - assertEqual(incremental.reusedCount, 1, '本地 full catalog 缓存缺失时仍应通过 OSS HEAD 复用未变化文件。'); + assertEqual( + incremental.uploadedCount, + 2, + '增量 files full 只应上传新增和变化文件。', + ); + assertEqual( + incremental.reusedCount, + 1, + '本地 full catalog 缓存缺失时仍应通过 OSS HEAD 复用未变化文件。', + ); } async function assertDirectFilesMigratesLegacyStateAndPrunesEmbeddedCatalogs() { const root = path.join(tmpRoot, 'direct-files-state-migration'); const dataDir = path.join(root, 'stdb'); const workDir = path.join(root, 'work'); - mkdirSync(dataDir, {recursive: true}); + mkdirSync(dataDir, { recursive: true }); writeFileSync(path.join(dataDir, 'control-db'), 'control'); const harness = createDirectOssHarness(); const options = { - mode: 'full', dataDir, workDir, database: 'test-db', bucket: 'backup-bucket', objectPrefix: 'database-backups', - uploadOptions: {}, uploadFn: harness.uploadFn, uploadManifestFn: harness.uploadManifestFn, verifyFn: harness.verifyFn, + mode: 'full', + dataDir, + workDir, + database: 'test-db', + bucket: 'backup-bucket', + objectPrefix: 'database-backups', + uploadOptions: {}, + uploadFn: harness.uploadFn, + uploadManifestFn: harness.uploadManifestFn, + verifyFn: harness.verifyFn, }; const first = await runDirectFilesBackup(options); const compactState = readGzipJson(first.statePath); - const catalog = JSON.parse(gunzipSync(readFileSync(`${first.catalogPath}.gz`)).toString('utf8')); + const catalog = JSON.parse( + gunzipSync(readFileSync(`${first.catalogPath}.gz`)).toString('utf8'), + ); const legacyCatalogRef = { ...compactState.latestCatalog, files: catalog.files, symlinks: catalog.symlinks, }; const legacyStatePath = first.statePath.slice(0, -3); - writeFileSync(legacyStatePath, `${JSON.stringify({ - ...compactState, - schemaVersion: 1, - baselineCatalog: legacyCatalogRef, - latestCatalog: legacyCatalogRef, - }, null, 2)}\n`); - rmSync(first.statePath, {force: false}); + writeFileSync( + legacyStatePath, + `${JSON.stringify( + { + ...compactState, + schemaVersion: 1, + baselineCatalog: legacyCatalogRef, + latestCatalog: legacyCatalogRef, + }, + null, + 2, + )}\n`, + ); + rmSync(first.statePath, { force: false }); const migrated = await runDirectFilesBackup(options); - assertTrue(migrated.unchanged, '旧 state 迁移不得改变相同 full catalog 的零上传语义。'); - assertTrue(existsSync(migrated.statePath), '旧 state 成功运行后必须生成压缩 state。'); - assertTrue(!existsSync(legacyStatePath), '压缩 state 原子落盘后应删除旧未压缩 state。'); + assertTrue( + migrated.unchanged, + '旧 state 迁移不得改变相同 full catalog 的零上传语义。', + ); + assertTrue( + existsSync(migrated.statePath), + '旧 state 成功运行后必须生成压缩 state。', + ); + assertTrue( + !existsSync(legacyStatePath), + '压缩 state 原子落盘后应删除旧未压缩 state。', + ); const migratedState = readGzipJson(migrated.statePath); assertEqual(migratedState.schemaVersion, 2, '旧 state 必须迁移到 v2。'); - assertTrue(!Object.hasOwn(migratedState.latestCatalog, 'files'), '迁移后 state 不得保留重复 files 清单。'); + assertTrue( + !Object.hasOwn(migratedState.latestCatalog, 'files'), + '迁移后 state 不得保留重复 files 清单。', + ); const latestCatalogPath = `${migrated.catalogPath}.gz`; const validCatalogBody = readFileSync(latestCatalogPath); @@ -388,7 +655,10 @@ async function assertDirectFilesMigratesLegacyStateAndPrunesEmbeddedCatalogs() { ); writeFileSync(latestCatalogPath, validCatalogBody); - writeFileSync(legacyStatePath, `${JSON.stringify({...migratedState, schemaVersion: 1})}\n`); + writeFileSync( + legacyStatePath, + `${JSON.stringify({ ...migratedState, schemaVersion: 1 })}\n`, + ); writeFileSync(migrated.statePath, 'not-a-gzip-state'); let corruptStateFailure = null; try { @@ -396,15 +666,21 @@ async function assertDirectFilesMigratesLegacyStateAndPrunesEmbeddedCatalogs() { } catch (error) { corruptStateFailure = error; } - assertTrue(corruptStateFailure instanceof Error, '压缩 state 损坏时必须失败。'); - assertTrue(existsSync(legacyStatePath), '压缩 state 损坏时不得静默回退并删除旧 state。'); + assertTrue( + corruptStateFailure instanceof Error, + '压缩 state 损坏时必须失败。', + ); + assertTrue( + existsSync(legacyStatePath), + '压缩 state 损坏时不得静默回退并删除旧 state。', + ); } async function assertDirectFilesConcurrencyIsBounded() { const root = path.join(tmpRoot, 'direct-files-concurrency'); const dataDir = path.join(root, 'stdb'); const workDir = path.join(root, 'work'); - mkdirSync(dataDir, {recursive: true}); + mkdirSync(dataDir, { recursive: true }); for (let index = 0; index < 12; index += 1) { writeFileSync(path.join(dataDir, `file-${index}.bin`), `content-${index}`); } @@ -439,8 +715,13 @@ async function assertDirectFilesConcurrencyIsBounded() { } async function assertDirectHistoryPublishesCatalogBeforeCleanup() { - const fixture = createHistoryFixture('direct-files-history-cleanup', {nestedData: false}); - createReplicaHistory(fixture.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const fixture = createHistoryFixture('direct-files-history-cleanup', { + nestedData: false, + }); + createReplicaHistory(fixture.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); const harness = createDirectOssHarness(); const common = { dataDir: fixture.dataDir, @@ -452,15 +733,29 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() { uploadFn: harness.uploadFn, verifyFn: harness.verifyFn, }; - const baseline = await runDirectFilesBackup({...common, mode: 'full', uploadManifestFn: harness.uploadManifestFn}); - const legacyResultFile = path.join(fixture.workDir, 'legacy-full-result.json'); - const legacyCatalogWithoutSymlinks = {...baseline.catalog}; + const baseline = await runDirectFilesBackup({ + ...common, + mode: 'full', + uploadManifestFn: harness.uploadManifestFn, + }); + const legacyResultFile = path.join( + fixture.workDir, + 'legacy-full-result.json', + ); + const legacyCatalogWithoutSymlinks = { ...baseline.catalog }; delete legacyCatalogWithoutSymlinks.symlinks; - writeFileSync(legacyResultFile, `${JSON.stringify({ - ...baseline, - catalog: legacyCatalogWithoutSymlinks, - }, null, 2)}\n`); - const plan = discoverHistoryPlan({dataDir: fixture.dataDir}); + writeFileSync( + legacyResultFile, + `${JSON.stringify( + { + ...baseline, + catalog: legacyCatalogWithoutSymlinks, + }, + null, + 2, + )}\n`, + ); + const plan = discoverHistoryPlan({ dataDir: fixture.dataDir }); let failure = null; try { await runDirectFilesBackup({ @@ -473,9 +768,16 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() { } catch (error) { failure = error; } - assertIncludes(failure?.message ?? '', 'synthetic direct catalog failure', 'direct history catalog 发布失败必须向上返回。'); + assertIncludes( + failure?.message ?? '', + 'synthetic direct catalog failure', + 'direct history catalog 发布失败必须向上返回。', + ); for (const candidate of plan.candidates) { - assertTrue(existsSync(path.join(fixture.dataDir, candidate.path)), `direct history catalog 发布失败不得删除: ${candidate.path}`); + assertTrue( + existsSync(path.join(fixture.dataDir, candidate.path)), + `direct history catalog 发布失败不得删除: ${candidate.path}`, + ); } let pointerFailure = null; @@ -494,9 +796,16 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() { } catch (error) { pointerFailure = error; } - assertIncludes(pointerFailure?.message ?? '', 'synthetic latest pointer HEAD failure', 'latest pointer HEAD 验真失败必须向上返回。'); + assertIncludes( + pointerFailure?.message ?? '', + 'synthetic latest pointer HEAD failure', + 'latest pointer HEAD 验真失败必须向上返回。', + ); for (const candidate of plan.candidates) { - assertTrue(existsSync(path.join(fixture.dataDir, candidate.path)), `latest pointer 发布失败不得删除: ${candidate.path}`); + assertTrue( + existsSync(path.join(fixture.dataDir, candidate.path)), + `latest pointer 发布失败不得删除: ${candidate.path}`, + ); } const resultFile = path.join(fixture.workDir, 'history-result.json'); @@ -506,33 +815,80 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() { resultFile, uploadManifestFn: harness.uploadManifestFn, }); - assertEqual(success.uploadedCount, 0, 'history 文件已在 full CAS baseline 时不应重复上传内容。'); + assertEqual( + success.uploadedCount, + 0, + 'history 文件已在 full CAS baseline 时不应重复上传内容。', + ); for (const file of success.catalog.files) { assertTrue( harness.verifiedKeys.includes(file.objectKey), `history 清理前必须逐个验真 baseline 复用对象: ${file.path}`, ); } - assertEqual(success.cleanup?.deletedCount, plan.candidates.length, 'catalog 和 baseline 验真后才应清理全部安全候选。'); + assertEqual( + success.cleanup?.deletedCount, + plan.candidates.length, + 'catalog 和 baseline 验真后才应清理全部安全候选。', + ); const state = readGzipJson(success.statePath); - assertEqual(state.schemaVersion, 2, 'files state 必须迁移为去重后的 v2 契约。'); - assertTrue(!Object.hasOwn(state.latestCatalog, 'files'), 'files state latest ref 不得重复嵌入 files。'); - assertTrue(state.historyCatalogs.every((catalog) => !Object.hasOwn(catalog, 'files')), 'files state history ref 不得重复嵌入 files。'); - assertTrue(!existsSync(success.catalogPath), '已上传并验真的 history catalog 本地 JSON 应被清理。'); - assertTrue(!existsSync(`${success.catalogPath}.gz`), 'history catalog 本地压缩副本也不应保留。'); + assertEqual( + state.schemaVersion, + 2, + 'files state 必须迁移为去重后的 v2 契约。', + ); + assertTrue( + !Object.hasOwn(state.latestCatalog, 'files'), + 'files state latest ref 不得重复嵌入 files。', + ); + assertTrue( + state.historyCatalogs.every((catalog) => !Object.hasOwn(catalog, 'files')), + 'files state history ref 不得重复嵌入 files。', + ); + assertTrue( + !existsSync(success.catalogPath), + '已上传并验真的 history catalog 本地 JSON 应被清理。', + ); + assertTrue( + !existsSync(`${success.catalogPath}.gz`), + 'history catalog 本地压缩副本也不应保留。', + ); const diskResult = JSON.parse(readFileSync(resultFile, 'utf8')); - assertTrue(!Object.hasOwn(diskResult.catalog, 'files'), 'files result 文件不得重复写入完整 files 清单。'); - assertEqual(diskResult.catalog.fileCount, success.fileCount, '紧凑 result 仍应保留文件计数。'); - const compactedLegacyResult = JSON.parse(readFileSync(legacyResultFile, 'utf8')); - assertTrue(!Object.hasOwn(compactedLegacyResult.catalog, 'files'), '旧 result 中重复的 files 清单应在成功运行后压缩。'); - assertEqual(compactedLegacyResult.catalog.symlinkCount, 0, '缺少 symlinks 的旧 result 应按零个符号链接兼容迁移。'); - assertTrue((success.metadataCleanup?.compactedResultCount ?? 0) >= 1, 'metadata 清理应报告已压缩旧 result。'); + assertTrue( + !Object.hasOwn(diskResult.catalog, 'files'), + 'files result 文件不得重复写入完整 files 清单。', + ); + assertEqual( + diskResult.catalog.fileCount, + success.fileCount, + '紧凑 result 仍应保留文件计数。', + ); + const compactedLegacyResult = JSON.parse( + readFileSync(legacyResultFile, 'utf8'), + ); + assertTrue( + !Object.hasOwn(compactedLegacyResult.catalog, 'files'), + '旧 result 中重复的 files 清单应在成功运行后压缩。', + ); + assertEqual( + compactedLegacyResult.catalog.symlinkCount, + 0, + '缺少 symlinks 的旧 result 应按零个符号链接兼容迁移。', + ); + assertTrue( + (success.metadataCleanup?.compactedResultCount ?? 0) >= 1, + 'metadata 清理应报告已压缩旧 result。', + ); const historyCatalogObjectKey = state.historyCatalogs[0].objectKey; harness.objects.delete(historyCatalogObjectKey); let brokenHistoryFailure = null; try { - await runDirectFilesBackup({...common, mode: 'history', uploadManifestFn: harness.uploadManifestFn}); + await runDirectFilesBackup({ + ...common, + mode: 'history', + uploadManifestFn: harness.uploadManifestFn, + }); } catch (error) { brokenHistoryFailure = error; } @@ -544,8 +900,13 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() { } async function assertDirectHistoryWithoutCandidatesPublishesLatest() { - const fixture = createHistoryFixture('direct-files-history-empty', {nestedData: false}); - createReplicaHistory(fixture.replicasDir, '1', {snapshots: [10], segments: [0]}); + const fixture = createHistoryFixture('direct-files-history-empty', { + nestedData: false, + }); + createReplicaHistory(fixture.replicasDir, '1', { + snapshots: [10], + segments: [0], + }); const harness = createDirectOssHarness(); const common = { dataDir: fixture.dataDir, @@ -558,13 +919,19 @@ async function assertDirectHistoryWithoutCandidatesPublishesLatest() { uploadManifestFn: harness.uploadManifestFn, verifyFn: harness.verifyFn, }; - await runDirectFilesBackup({...common, mode: 'full'}); + await runDirectFilesBackup({ ...common, mode: 'full' }); const latestObjectKey = 'database-backups/test-db/latest.json'; harness.objects.delete(latestObjectKey); - const result = await runDirectFilesBackup({...common, mode: 'history'}); + const result = await runDirectFilesBackup({ ...common, mode: 'history' }); assertEqual(result.candidateCount, 0, 'fixture 应没有可归档 history 候选。'); - assertTrue(harness.objects.has(latestObjectKey), 'history 无候选时仍必须从现有 state 发布 latest pointer。'); - assertTrue(harness.verifiedKeys.includes(latestObjectKey), 'history 无候选时 latest pointer 仍必须 HEAD 验真。'); + assertTrue( + harness.objects.has(latestObjectKey), + 'history 无候选时仍必须从现有 state 发布 latest pointer。', + ); + assertTrue( + harness.verifiedKeys.includes(latestObjectKey), + 'history 无候选时 latest pointer 仍必须 HEAD 验真。', + ); } async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { @@ -572,23 +939,39 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { const dataDir = path.join(root, 'stdb'); const workDir = path.join(root, 'work'); const restoreDir = path.join(root, 'restore'); - mkdirSync(path.join(dataDir, 'empty-directory'), {recursive: true}); - mkdirSync(path.join(dataDir, 'config'), {recursive: true}); - mkdirSync(path.join(dataDir, 'bin', '2.6.0'), {recursive: true}); + mkdirSync(path.join(dataDir, 'empty-directory'), { recursive: true }); + mkdirSync(path.join(dataDir, 'config'), { recursive: true }); + mkdirSync(path.join(dataDir, 'bin', '2.6.0'), { recursive: true }); symlinkSync('2.6.0', path.join(dataDir, 'bin', 'current')); const keyPath = path.join(dataDir, 'config', 'id_ecdsa'); writeFileSync(keyPath, 'private key fixture'); chmodSync(keyPath, 0o640); const harness = createDirectOssHarness(); await runDirectFilesBackup({ - mode: 'full', dataDir, workDir, database: 'test-db', bucket: 'backup-bucket', objectPrefix: 'database-backups', - uploadOptions: {}, uploadFn: harness.uploadFn, uploadManifestFn: harness.uploadManifestFn, verifyFn: harness.verifyFn, + mode: 'full', + dataDir, + workDir, + database: 'test-db', + bucket: 'backup-bucket', + objectPrefix: 'database-backups', + uploadOptions: {}, + uploadFn: harness.uploadFn, + uploadManifestFn: harness.uploadManifestFn, + verifyFn: harness.verifyFn, }); writeFileSync(keyPath, 'updated private key fixture'); chmodSync(keyPath, 0o640); const latestFull = await runDirectFilesBackup({ - mode: 'full', dataDir, workDir, database: 'test-db', bucket: 'backup-bucket', objectPrefix: 'database-backups', - uploadOptions: {}, uploadFn: harness.uploadFn, uploadManifestFn: harness.uploadManifestFn, verifyFn: harness.verifyFn, + mode: 'full', + dataDir, + workDir, + database: 'test-db', + bucket: 'backup-bucket', + objectPrefix: 'database-backups', + uploadOptions: {}, + uploadFn: harness.uploadFn, + uploadManifestFn: harness.uploadManifestFn, + verifyFn: harness.verifyFn, }); const restored = await restoreDirectFilesBackup({ statePath: latestFull.statePath, @@ -596,8 +979,9 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { database: 'test-db', bucket: 'backup-bucket', uploadOptions: {}, - downloadBufferFn: async ({objectKey}) => Buffer.from(harness.objects.get(objectKey)?.body ?? ''), - downloadFileFn: async ({objectKey, destinationPath}) => { + downloadBufferFn: async ({ objectKey }) => + Buffer.from(harness.objects.get(objectKey)?.body ?? ''), + downloadFileFn: async ({ objectKey, destinationPath }) => { const object = harness.objects.get(objectKey); if (!object) { throw new Error(`missing ${objectKey}`); @@ -605,27 +989,56 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { writeFileSync(destinationPath, object.body); }, }); - assertEqual(restored.downloadedCount, 1, 'files restore 必须从对象存储下载 catalog 中的普通文件。'); - assertEqual(readFileSync(path.join(restoreDir, 'config', 'id_ecdsa'), 'utf8'), 'updated private key fixture', 'files restore 必须按最新 full catalog 的原相对路径恢复内容。'); - assertTrue(existsSync(path.join(restoreDir, 'empty-directory')), 'files restore 必须重建空目录。'); - assertEqual(statSync(path.join(restoreDir, 'config', 'id_ecdsa')).mode & 0o7777, 0o640, 'files restore 必须恢复文件权限。'); - assertTrue(lstatSync(path.join(restoreDir, 'bin', 'current')).isSymbolicLink(), 'files restore 必须重建符号链接。'); - assertEqual(readlinkSync(path.join(restoreDir, 'bin', 'current'), 'utf8'), '2.6.0', 'files restore 必须保留符号链接目标。'); + assertEqual( + restored.downloadedCount, + 1, + 'files restore 必须从对象存储下载 catalog 中的普通文件。', + ); + assertEqual( + readFileSync(path.join(restoreDir, 'config', 'id_ecdsa'), 'utf8'), + 'updated private key fixture', + 'files restore 必须按最新 full catalog 的原相对路径恢复内容。', + ); + assertTrue( + existsSync(path.join(restoreDir, 'empty-directory')), + 'files restore 必须重建空目录。', + ); + assertEqual( + statSync(path.join(restoreDir, 'config', 'id_ecdsa')).mode & 0o7777, + 0o640, + 'files restore 必须恢复文件权限。', + ); + assertTrue( + lstatSync(path.join(restoreDir, 'bin', 'current')).isSymbolicLink(), + 'files restore 必须重建符号链接。', + ); + assertEqual( + readlinkSync(path.join(restoreDir, 'bin', 'current'), 'utf8'), + '2.6.0', + 'files restore 必须保留符号链接目标。', + ); - rmSync(restoreDir, {recursive: true, force: true}); - const legacyRestoreStatePath = path.join(workDir, 'legacy-restore-state.json'); - writeFileSync(legacyRestoreStatePath, `${JSON.stringify({ - ...readGzipJson(latestFull.statePath), - schemaVersion: 1, - })}\n`); + rmSync(restoreDir, { recursive: true, force: true }); + const legacyRestoreStatePath = path.join( + workDir, + 'legacy-restore-state.json', + ); + writeFileSync( + legacyRestoreStatePath, + `${JSON.stringify({ + ...readGzipJson(latestFull.statePath), + schemaVersion: 1, + })}\n`, + ); const legacyRestored = await restoreDirectFilesBackup({ statePath: legacyRestoreStatePath, restoreDir, database: 'test-db', bucket: 'backup-bucket', uploadOptions: {}, - downloadBufferFn: async ({objectKey}) => Buffer.from(harness.objects.get(objectKey)?.body ?? ''), - downloadFileFn: async ({objectKey, destinationPath}) => { + downloadBufferFn: async ({ objectKey }) => + Buffer.from(harness.objects.get(objectKey)?.body ?? ''), + downloadFileFn: async ({ objectKey, destinationPath }) => { const object = harness.objects.get(objectKey); if (!object) { throw new Error(`missing ${objectKey}`); @@ -633,10 +1046,14 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { writeFileSync(destinationPath, object.body); }, }); - assertEqual(legacyRestored.downloadedCount, 1, 'files restore 必须继续兼容 v1 JSON state。'); + assertEqual( + legacyRestored.downloadedCount, + 1, + 'files restore 必须继续兼容 v1 JSON state。', + ); - rmSync(restoreDir, {recursive: true, force: true}); - const downloadBufferFn = async ({objectKey}) => { + rmSync(restoreDir, { recursive: true, force: true }); + const downloadBufferFn = async ({ objectKey }) => { const object = harness.objects.get(objectKey); if (!object) { throw new Error(`missing ${objectKey}`); @@ -644,7 +1061,7 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { return Buffer.from(object.body); }; let objectDownloadCount = 0; - const downloadFileFn = async ({objectKey, destinationPath}) => { + const downloadFileFn = async ({ objectKey, destinationPath }) => { objectDownloadCount += 1; const object = harness.objects.get(objectKey); if (!object) { @@ -663,10 +1080,26 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { downloadFileFn, verifyFn: harness.verifyFn, }); - assertEqual(dryRun.catalogId, latestFull.catalogId, 'OSS-only dry-run 必须选择 latestFullCatalog。'); - assertEqual(dryRun.fileCount, 1, 'OSS-only dry-run 应返回 full catalog 文件数。'); - assertEqual(dryRun.symlinkCount, 1, 'OSS-only dry-run 应返回 full catalog 符号链接数。'); - assertEqual(dryRun.totalSizeBytes, String(Buffer.byteLength('updated private key fixture')), 'OSS-only dry-run 应返回总字节数。'); + assertEqual( + dryRun.catalogId, + latestFull.catalogId, + 'OSS-only dry-run 必须选择 latestFullCatalog。', + ); + assertEqual( + dryRun.fileCount, + 1, + 'OSS-only dry-run 应返回 full catalog 文件数。', + ); + assertEqual( + dryRun.symlinkCount, + 1, + 'OSS-only dry-run 应返回 full catalog 符号链接数。', + ); + assertEqual( + dryRun.totalSizeBytes, + String(Buffer.byteLength('updated private key fixture')), + 'OSS-only dry-run 应返回总字节数。', + ); assertEqual(objectDownloadCount, 0, 'OSS-only dry-run 不得下载数据对象。'); assertTrue(!existsSync(restoreDir), 'OSS-only dry-run 不得创建恢复目录。'); @@ -680,16 +1113,36 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { downloadFileFn, verifyFn: harness.verifyFn, }); - assertEqual(latestRestored.catalogId, latestFull.catalogId, 'OSS-only restore 必须选择 latestFullCatalog。'); - assertEqual(latestRestored.downloadedCount, 1, 'OSS-only restore 应下载 latest full catalog 的数据对象。'); - assertEqual(readFileSync(path.join(restoreDir, 'config', 'id_ecdsa'), 'utf8'), 'updated private key fixture', 'OSS-only restore 应还原最新 full 内容。'); - assertEqual(readlinkSync(path.join(restoreDir, 'bin', 'current'), 'utf8'), '2.6.0', 'OSS-only restore 应还原符号链接。'); + assertEqual( + latestRestored.catalogId, + latestFull.catalogId, + 'OSS-only restore 必须选择 latestFullCatalog。', + ); + assertEqual( + latestRestored.downloadedCount, + 1, + 'OSS-only restore 应下载 latest full catalog 的数据对象。', + ); + assertEqual( + readFileSync(path.join(restoreDir, 'config', 'id_ecdsa'), 'utf8'), + 'updated private key fixture', + 'OSS-only restore 应还原最新 full 内容。', + ); + assertEqual( + readlinkSync(path.join(restoreDir, 'bin', 'current'), 'utf8'), + '2.6.0', + 'OSS-only restore 应还原符号链接。', + ); } function assertCanonicalQueryAndAuthorizationIncludeMultipartParameters() { - assertEqual(buildCanonicalQuery({uploads: null}), 'uploads', 'InitiateMultipartUpload 必须使用无等号的 uploads 参数。'); assertEqual( - buildCanonicalQuery({uploadId: 'abc+/= xyz', partNumber: 12}), + buildCanonicalQuery({ uploads: null }), + 'uploads', + 'InitiateMultipartUpload 必须使用无等号的 uploads 参数。', + ); + assertEqual( + buildCanonicalQuery({ uploadId: 'abc+/= xyz', partNumber: 12 }), 'partNumber=12&uploadId=abc%2B%2F%3D%20xyz', 'multipart query 必须按 key 排序并使用 RFC3986 编码。', ); @@ -720,9 +1173,13 @@ function assertCanonicalQueryAndAuthorizationIncludeMultipartParameters() { accessKeySecret: 'test-access-secret', headers, date, - queries: {partNumber: 12, uploadId: 'abc+/= xyz'}, + queries: { partNumber: 12, uploadId: 'abc+/= xyz' }, }); - assertNotEqual(withQuery, withoutQuery, 'multipart query 必须参与 V4 Authorization 计算。'); + assertNotEqual( + withQuery, + withoutQuery, + 'multipart query 必须参与 V4 Authorization 计算。', + ); assertEqual( withQuery, 'OSS4-HMAC-SHA256 Credential=test-access-key/20260713/cn-shanghai/oss/aliyun_v4_request,AdditionalHeaders=host,Signature=9323dd3b7272b52f416c4d32115fcc00460eaccdcdaf011575c2502a63a27b1f', @@ -742,12 +1199,41 @@ function assertInsufficientSpaceStopsBeforeServiceChanges() { ]); assertStatus(result, 1, '空间不足时必须失败。'); - assertIncludes(result.stdout, '备份空间预检', '空间不足失败前应打印空间预检。'); - assertIncludes(result.stderr, '剩余空间不足', '空间不足失败应说明剩余空间不足。'); + assertIncludes( + result.stdout, + '备份空间预检', + '空间不足失败前应打印空间预检。', + ); + assertIncludes( + result.stderr, + '剩余空间不足', + '空间不足失败应说明剩余空间不足。', + ); assertFileMissing(fixture.systemctlLog, '空间不足时不能调用 systemctl。'); assertFileMissing(fixture.tarLog, '空间不足时不能调用 tar。'); } +function assertStopFailureRetainsRecoveryMarker() { + const fixture = createFixture('stop-failure-marker'); + writeExecutable( + path.join(fixture.binDir, 'systemctl'), + `#!/usr/bin/env bash +printf 'systemctl %s\\n' "$*" >> "${fixture.systemctlLog}" +if [ "$1" = stop ]; then + exit 9 +fi +exit 0 +`, + ); + const result = runBackup(fixture, ['--stop-service', 'spacetimedb.service']); + + assertStatus(result, 1, '停止服务失败时备份必须失败。'); + assertTrue( + existsSync(path.join(fixture.workDir, '.spacetimedb-stopped')), + '停止服务命令失败时必须保留 marker,供 systemd ExecStopPost 兜底恢复。', + ); +} + function assertArchiveFailureStillRestoresDependentServices() { const fixture = createFixture('tar-failure'); const result = runBackup(fixture, [ @@ -764,7 +1250,11 @@ function assertArchiveFailureStillRestoresDependentServices() { ]); assertStatus(result, 1, 'tar 失败时备份脚本必须失败。'); - assertIncludes(result.stderr, 'fake tar failure', 'tar 失败原因应保留在错误输出中。'); + assertIncludes( + result.stderr, + 'fake tar failure', + 'tar 失败原因应保留在错误输出中。', + ); const systemctlLog = readFile(fixture.systemctlLog); const expectedCommands = [ 'systemctl stop spacetimedb.service', @@ -776,6 +1266,10 @@ function assertArchiveFailureStillRestoresDependentServices() { for (const command of expectedCommands) { assertIncludes(systemctlLog, command, `tar 失败后必须执行: ${command}`); } + assertFileMissing( + path.join(fixture.workDir, '.spacetimedb-stopped'), + '正常执行 finally 恢复全部服务后必须清理停库 marker。', + ); } async function assertMultipartUploadRetriesAndVerifiesRemoteLength() { @@ -788,7 +1282,7 @@ async function assertMultipartUploadRetriesAndVerifiesRemoteLength() { Buffer.alloc(17, 'c'), ]); const payloadSha256 = createHash('sha256').update(payload).digest('hex'); - mkdirSync(root, {recursive: true}); + mkdirSync(root, { recursive: true }); writeFileSync(archivePath, payload); const requests = []; @@ -797,30 +1291,50 @@ async function assertMultipartUploadRetriesAndVerifiesRemoteLength() { const uploadId = 'upload+/= id'; const fetchImpl = async (url, options) => { const body = await readRequestBody(options.body); - requests.push({url, method: options.method, headers: options.headers, body}); + requests.push({ + url, + method: options.method, + headers: options.headers, + body, + }); const parsedUrl = new URL(url); if (options.method === 'POST' && parsedUrl.search === '?uploads') { - return new Response(`${uploadId}`, {status: 200}); + return new Response( + `${uploadId}`, + { status: 200 }, + ); } if (options.method === 'PUT') { const partNumber = Number(parsedUrl.searchParams.get('partNumber')); if (partNumber === 1) { firstPartAttempts += 1; if (firstPartAttempts === 1) { - return new Response('ServiceUnavailable', {status: 503}); + return new Response( + 'ServiceUnavailable', + { status: 503 }, + ); } } - return new Response('', {status: 200, headers: {etag: `"etag-${partNumber}"`}}); + return new Response('', { + status: 200, + headers: { etag: `"etag-${partNumber}"` }, + }); } if (options.method === 'POST' && parsedUrl.searchParams.has('uploadId')) { - return new Response('', {status: 200, headers: {etag: '"complete-etag"'}}); + return new Response('', { + status: 200, + headers: { etag: '"complete-etag"' }, + }); } if (options.method === 'HEAD') { - return new Response(null, {status: 200, headers: { - 'content-length': String(payload.length), - 'x-oss-meta-archive-sha256': payloadSha256, - }}); + return new Response(null, { + status: 200, + headers: { + 'content-length': String(payload.length), + 'x-oss-meta-archive-sha256': payloadSha256, + }, + }); } throw new Error(`unexpected request: ${options.method} ${url}`); }; @@ -842,32 +1356,86 @@ async function assertMultipartUploadRetriesAndVerifiesRemoteLength() { randomFn: () => 0, }); - assertEqual(result.uploadMode, 'multipart', '上传结果必须记录 multipart 模式。'); + assertEqual( + result.uploadMode, + 'multipart', + '上传结果必须记录 multipart 模式。', + ); assertEqual(result.partCount, 3, 'multipart 应按配置大小切成三段。'); - assertEqual(result.contentLength, payload.length, '上传结果应保留完整归档长度。'); - assertEqual(result.etag, 'complete-etag', '上传结果应保留 CompleteMultipartUpload ETag。'); + assertEqual( + result.contentLength, + payload.length, + '上传结果应保留完整归档长度。', + ); + assertEqual( + result.etag, + 'complete-etag', + '上传结果应保留 CompleteMultipartUpload ETag。', + ); assertEqual(firstPartAttempts, 2, '503 后应仅重试失败的第一段。'); assertEqual(retryDelays.length, 1, '一次可重试失败应触发一次退避。'); const initiateRequest = requests[0]; - assertTrue(initiateRequest.url.endsWith('?uploads'), 'InitiateMultipartUpload URL 必须使用裸 uploads 参数。'); - assertTrue(!initiateRequest.url.endsWith('?uploads='), 'InitiateMultipartUpload URL 不能把裸参数写成 uploads=。'); - assertEqual(initiateRequest.headers['x-oss-meta-archive-sha256'], payloadSha256, 'multipart 对象必须保存本地归档 SHA-256 元数据。'); - const firstPartRequests = requests.filter(({method, url}) => method === 'PUT' && new URL(url).searchParams.get('partNumber') === '1'); - assertEqual(firstPartRequests.length, 2, '第一段应产生原请求和一次重试。'); - assertBufferEqual(firstPartRequests[0].body, payload.subarray(0, partSizeBytes), '第一段原请求内容必须完整。'); - assertBufferEqual(firstPartRequests[1].body, payload.subarray(0, partSizeBytes), '第一段重试必须重新创建并完整读取 stream。'); assertTrue( - firstPartRequests[0].url.includes('?partNumber=1&uploadId=upload%2B%2F%3D%20id'), + initiateRequest.url.endsWith('?uploads'), + 'InitiateMultipartUpload URL 必须使用裸 uploads 参数。', + ); + assertTrue( + !initiateRequest.url.endsWith('?uploads='), + 'InitiateMultipartUpload URL 不能把裸参数写成 uploads=。', + ); + assertEqual( + initiateRequest.headers['x-oss-meta-archive-sha256'], + payloadSha256, + 'multipart 对象必须保存本地归档 SHA-256 元数据。', + ); + const firstPartRequests = requests.filter( + ({ method, url }) => + method === 'PUT' && new URL(url).searchParams.get('partNumber') === '1', + ); + assertEqual(firstPartRequests.length, 2, '第一段应产生原请求和一次重试。'); + assertBufferEqual( + firstPartRequests[0].body, + payload.subarray(0, partSizeBytes), + '第一段原请求内容必须完整。', + ); + assertBufferEqual( + firstPartRequests[1].body, + payload.subarray(0, partSizeBytes), + '第一段重试必须重新创建并完整读取 stream。', + ); + assertTrue( + firstPartRequests[0].url.includes( + '?partNumber=1&uploadId=upload%2B%2F%3D%20id', + ), 'UploadPart URL 必须使用排序并编码后的 canonical query。', ); - const completeRequest = requests.find(({method, url}) => method === 'POST' && new URL(url).searchParams.has('uploadId')); - assertIncludes(completeRequest?.body.toString('utf8') ?? '', '1"etag-1"', 'Complete XML 应包含第一段 ETag。'); - assertIncludes(completeRequest?.body.toString('utf8') ?? '', '3"etag-3"', 'Complete XML 应包含最后一段 ETag。'); - assertTrue(requests.some(({method}) => method === 'HEAD'), 'Complete 后必须执行签名 HEAD 验证。'); + const completeRequest = requests.find( + ({ method, url }) => + method === 'POST' && new URL(url).searchParams.has('uploadId'), + ); + assertIncludes( + completeRequest?.body.toString('utf8') ?? '', + '1"etag-1"', + 'Complete XML 应包含第一段 ETag。', + ); + assertIncludes( + completeRequest?.body.toString('utf8') ?? '', + '3"etag-3"', + 'Complete XML 应包含最后一段 ETag。', + ); + assertTrue( + requests.some(({ method }) => method === 'HEAD'), + 'Complete 后必须执行签名 HEAD 验证。', + ); for (const request of requests) { - assertTrue(String(request.headers.authorization ?? '').startsWith('OSS4-HMAC-SHA256 '), `${request.method} 请求必须携带 V4 Authorization。`); + assertTrue( + String(request.headers.authorization ?? '').startsWith( + 'OSS4-HMAC-SHA256 ', + ), + `${request.method} 请求必须携带 V4 Authorization。`, + ); } } @@ -877,31 +1445,43 @@ async function assertHeadLengthMismatchAbortsMultipartUpload() { const partSizeBytes = 100 * 1024; const payload = Buffer.alloc(partSizeBytes + 1, 'x'); const payloadSha256 = createHash('sha256').update(payload).digest('hex'); - mkdirSync(root, {recursive: true}); + mkdirSync(root, { recursive: true }); writeFileSync(archivePath, payload); const requests = []; const fetchImpl = async (url, options) => { await readRequestBody(options.body); - requests.push({url, method: options.method}); + requests.push({ url, method: options.method }); const parsedUrl = new URL(url); if (options.method === 'POST' && parsedUrl.search === '?uploads') { - return new Response('mismatch-upload', {status: 200}); + return new Response( + 'mismatch-upload', + { status: 200 }, + ); } if (options.method === 'PUT') { - return new Response('', {status: 200, headers: {etag: `"etag-${parsedUrl.searchParams.get('partNumber')}"`}}); + return new Response('', { + status: 200, + headers: { etag: `"etag-${parsedUrl.searchParams.get('partNumber')}"` }, + }); } if (options.method === 'POST') { - return new Response('', {status: 200, headers: {etag: '"complete-etag"'}}); + return new Response('', { + status: 200, + headers: { etag: '"complete-etag"' }, + }); } if (options.method === 'HEAD') { - return new Response(null, {status: 200, headers: { - 'content-length': String(payload.length - 1), - 'x-oss-meta-archive-sha256': payloadSha256, - }}); + return new Response(null, { + status: 200, + headers: { + 'content-length': String(payload.length - 1), + 'x-oss-meta-archive-sha256': payloadSha256, + }, + }); } if (options.method === 'DELETE') { - return new Response(null, {status: 204}); + return new Response(null, { status: 204 }); } throw new Error(`unexpected request: ${options.method} ${url}`); }; @@ -929,40 +1509,59 @@ async function assertHeadLengthMismatchAbortsMultipartUpload() { } assertTrue(uploadError instanceof Error, 'HEAD 长度不一致时上传必须失败。'); - assertIncludes(uploadError?.message ?? '', 'HEAD 验证长度不一致', 'HEAD 长度不一致错误应保留本地和远端长度。'); - const abortRequest = requests.find(({method}) => method === 'DELETE'); - assertTrue(Boolean(abortRequest), 'HEAD 长度不一致后必须 best-effort AbortMultipartUpload。'); - assertTrue(abortRequest?.url.endsWith('?uploadId=mismatch-upload'), 'AbortMultipartUpload 必须携带同一 uploadId。'); + assertIncludes( + uploadError?.message ?? '', + 'HEAD 验证长度不一致', + 'HEAD 长度不一致错误应保留本地和远端长度。', + ); + const abortRequest = requests.find(({ method }) => method === 'DELETE'); + assertTrue( + Boolean(abortRequest), + 'HEAD 长度不一致后必须 best-effort AbortMultipartUpload。', + ); + assertTrue( + abortRequest?.url.endsWith('?uploadId=mismatch-upload'), + 'AbortMultipartUpload 必须携带同一 uploadId。', + ); } async function assertHeadShaMismatchAbortsMultipartUpload() { const root = path.join(tmpRoot, 'multipart-head-sha-mismatch'); const archivePath = path.join(root, 'backup.tar.gz'); const payload = Buffer.alloc(100 * 1024, 's'); - mkdirSync(root, {recursive: true}); + mkdirSync(root, { recursive: true }); writeFileSync(archivePath, payload); const requests = []; const fetchImpl = async (url, options) => { await readRequestBody(options.body); - requests.push({url, method: options.method}); + requests.push({ url, method: options.method }); const parsedUrl = new URL(url); if (options.method === 'POST' && parsedUrl.search === '?uploads') { - return new Response('sha-mismatch-upload', {status: 200}); + return new Response( + 'sha-mismatch-upload', + { status: 200 }, + ); } if (options.method === 'PUT') { - return new Response('', {status: 200, headers: {etag: '"part-etag"'}}); + return new Response('', { + status: 200, + headers: { etag: '"part-etag"' }, + }); } if (options.method === 'POST') { - return new Response('', {status: 200}); + return new Response('', { status: 200 }); } if (options.method === 'HEAD') { - return new Response(null, {status: 200, headers: { - 'content-length': String(payload.length), - 'x-oss-meta-archive-sha256': '0'.repeat(64), - }}); + return new Response(null, { + status: 200, + headers: { + 'content-length': String(payload.length), + 'x-oss-meta-archive-sha256': '0'.repeat(64), + }, + }); } if (options.method === 'DELETE') { - return new Response(null, {status: 204}); + return new Response(null, { status: 204 }); } throw new Error(`unexpected request: ${options.method} ${url}`); }; @@ -985,9 +1584,16 @@ async function assertHeadShaMismatchAbortsMultipartUpload() { } catch (error) { uploadError = error; } - assertIncludes(uploadError?.message ?? '', 'SHA-256 不一致', 'HEAD SHA-256 不一致时上传必须失败。'); + assertIncludes( + uploadError?.message ?? '', + 'SHA-256 不一致', + 'HEAD SHA-256 不一致时上传必须失败。', + ); assertTrue( - requests.some(({method, url}) => method === 'DELETE' && url.endsWith('?uploadId=sha-mismatch-upload')), + requests.some( + ({ method, url }) => + method === 'DELETE' && url.endsWith('?uploadId=sha-mismatch-upload'), + ), 'HEAD SHA-256 不一致后必须 best-effort AbortMultipartUpload。', ); } @@ -995,9 +1601,14 @@ async function assertHeadShaMismatchAbortsMultipartUpload() { async function assertManifestUploadUsesShaAndHeadVerification() { const root = path.join(tmpRoot, 'manifest-upload'); const manifestPath = path.join(root, 'backup.manifest.json'); - const body = Buffer.from(JSON.stringify({uploadStatus: 'uploaded', catalog: 'x'.repeat(150 * 1024)})); + const body = Buffer.from( + JSON.stringify({ + uploadStatus: 'uploaded', + catalog: 'x'.repeat(150 * 1024), + }), + ); const bodySha256 = createHash('sha256').update(body).digest('hex'); - mkdirSync(root, {recursive: true}); + mkdirSync(root, { recursive: true }); writeFileSync(manifestPath, body); const requests = []; let limitedChunkCount = 0; @@ -1015,37 +1626,68 @@ async function assertManifestUploadUsesShaAndHeadVerification() { randomFn: () => 0, bandwidthLimiter: { wrap(readable) { - return Readable.from((async function* observeLimitedManifest() { - for await (const chunk of readable) { - limitedChunkCount += 1; - limitedBytes += chunk.length; - yield chunk; - } - })(), {objectMode: false}); + return Readable.from( + (async function* observeLimitedManifest() { + for await (const chunk of readable) { + limitedChunkCount += 1; + limitedBytes += chunk.length; + yield chunk; + } + })(), + { objectMode: false }, + ); }, }, fetchImpl: async (url, options) => { const requestBody = await readRequestBody(options.body); - requests.push({url, method: options.method, headers: options.headers, body: requestBody}); + requests.push({ + url, + method: options.method, + headers: options.headers, + body: requestBody, + }); if (options.method === 'PUT') { - return new Response(null, {status: 200}); + return new Response(null, { status: 200 }); } if (options.method === 'HEAD') { - return new Response(null, {status: 200, headers: { - 'x-oss-meta-file-size': String(body.length), - 'x-oss-meta-archive-sha256': bodySha256, - }}); + return new Response(null, { + status: 200, + headers: { + 'x-oss-meta-file-size': String(body.length), + 'x-oss-meta-archive-sha256': bodySha256, + }, + }); } throw new Error(`unexpected request: ${options.method} ${url}`); }, }); - assertEqual(result.archiveSha256, bodySha256, 'manifest 上传结果必须记录本地 SHA-256。'); - assertBufferEqual(requests.find(({method}) => method === 'PUT')?.body, body, 'manifest PUT 必须上传完整 JSON。'); - assertEqual(limitedBytes, body.length, 'manifest 必须完整经过上传带宽限制流。'); - assertTrue(limitedChunkCount > 1, '大型 manifest 必须分块经过限速器,不能整块突发上传。'); - assertTrue(requests.some(({method}) => method === 'HEAD'), 'manifest PUT 后必须执行 HEAD 验真。'); assertEqual( - requests.find(({method}) => method === 'PUT')?.headers['x-oss-meta-file-size'], + result.archiveSha256, + bodySha256, + 'manifest 上传结果必须记录本地 SHA-256。', + ); + assertBufferEqual( + requests.find(({ method }) => method === 'PUT')?.body, + body, + 'manifest PUT 必须上传完整 JSON。', + ); + assertEqual( + limitedBytes, + body.length, + 'manifest 必须完整经过上传带宽限制流。', + ); + assertTrue( + limitedChunkCount > 1, + '大型 manifest 必须分块经过限速器,不能整块突发上传。', + ); + assertTrue( + requests.some(({ method }) => method === 'HEAD'), + 'manifest PUT 后必须执行 HEAD 验真。', + ); + assertEqual( + requests.find(({ method }) => method === 'PUT')?.headers[ + 'x-oss-meta-file-size' + ], String(body.length), 'manifest PUT 必须记录原始字节数,供动态压缩 HEAD 缺少 content-length 时验真。', ); @@ -1054,22 +1696,25 @@ async function assertManifestUploadUsesShaAndHeadVerification() { async function assertMissingPartEtagAbortsMultipartUpload() { const root = path.join(tmpRoot, 'multipart-missing-etag'); const archivePath = path.join(root, 'backup.tar.gz'); - mkdirSync(root, {recursive: true}); + mkdirSync(root, { recursive: true }); writeFileSync(archivePath, Buffer.alloc(100 * 1024, 'e')); const requests = []; const fetchImpl = async (url, options) => { await readRequestBody(options.body); - requests.push({url, method: options.method}); + requests.push({ url, method: options.method }); const parsedUrl = new URL(url); if (options.method === 'POST' && parsedUrl.search === '?uploads') { - return new Response('missing-etag-upload', {status: 200}); + return new Response( + 'missing-etag-upload', + { status: 200 }, + ); } if (options.method === 'PUT') { - return new Response('', {status: 200}); + return new Response('', { status: 200 }); } if (options.method === 'DELETE') { - return new Response(null, {status: 204}); + return new Response(null, { status: 204 }); } throw new Error(`unexpected request: ${options.method} ${url}`); }; @@ -1094,9 +1739,16 @@ async function assertMissingPartEtagAbortsMultipartUpload() { uploadError = error; } - assertIncludes(uploadError?.message ?? '', '响应缺少 ETag', 'UploadPart 缺少 ETag 时必须失败。'); + assertIncludes( + uploadError?.message ?? '', + '响应缺少 ETag', + 'UploadPart 缺少 ETag 时必须失败。', + ); assertTrue( - requests.some(({method, url}) => method === 'DELETE' && url.endsWith('?uploadId=missing-etag-upload')), + requests.some( + ({ method, url }) => + method === 'DELETE' && url.endsWith('?uploadId=missing-etag-upload'), + ), 'UploadPart 缺少 ETag 后必须 AbortMultipartUpload。', ); } @@ -1106,36 +1758,47 @@ async function assertCompleteResponseAmbiguityUsesHeadVerification() { const archivePath = path.join(root, 'backup.tar.gz'); const payload = Buffer.alloc(100 * 1024, 'c'); const payloadSha256 = createHash('sha256').update(payload).digest('hex'); - mkdirSync(root, {recursive: true}); + mkdirSync(root, { recursive: true }); writeFileSync(archivePath, payload); const requests = []; let completeAttempts = 0; const fetchImpl = async (url, options) => { await readRequestBody(options.body); - requests.push({url, method: options.method}); + requests.push({ url, method: options.method }); const parsedUrl = new URL(url); if (options.method === 'POST' && parsedUrl.search === '?uploads') { - return new Response('ambiguous-upload', {status: 200}); + return new Response( + 'ambiguous-upload', + { status: 200 }, + ); } if (options.method === 'PUT') { - return new Response('', {status: 200, headers: {etag: '"part-etag"'}}); + return new Response('', { + status: 200, + headers: { etag: '"part-etag"' }, + }); } if (options.method === 'POST' && parsedUrl.searchParams.has('uploadId')) { completeAttempts += 1; if (completeAttempts === 1) { throw new TypeError('socket closed after remote complete'); } - return new Response('NoSuchUpload', {status: 404}); + return new Response('NoSuchUpload', { + status: 404, + }); } if (options.method === 'HEAD') { - return new Response(null, {status: 200, headers: { - 'content-length': String(payload.length), - 'x-oss-meta-archive-sha256': payloadSha256, - }}); + return new Response(null, { + status: 200, + headers: { + 'content-length': String(payload.length), + 'x-oss-meta-archive-sha256': payloadSha256, + }, + }); } if (options.method === 'DELETE') { - return new Response(null, {status: 204}); + return new Response(null, { status: 204 }); } throw new Error(`unexpected request: ${options.method} ${url}`); }; @@ -1158,13 +1821,23 @@ async function assertCompleteResponseAmbiguityUsesHeadVerification() { }); assertEqual(completeAttempts, 2, 'Complete 网络错误后应按策略重试。'); - assertEqual(result.contentLength, payload.length, 'Complete 结果不确定时应以 HEAD 长度验真收口。'); - assertTrue(requests.some(({method}) => method === 'HEAD'), 'Complete 结果不确定时必须执行 HEAD 验真。'); - assertTrue(!requests.some(({method}) => method === 'DELETE'), 'HEAD 已证实对象完整时不得 Abort 已完成上传。'); + assertEqual( + result.contentLength, + payload.length, + 'Complete 结果不确定时应以 HEAD 长度验真收口。', + ); + assertTrue( + requests.some(({ method }) => method === 'HEAD'), + 'Complete 结果不确定时必须执行 HEAD 验真。', + ); + assertTrue( + !requests.some(({ method }) => method === 'DELETE'), + 'HEAD 已证实对象完整时不得 Abort 已完成上传。', + ); } function assertHistoryDiscoversDevAndProductionLayoutsWithMultipleReplicas() { - const dev = createHistoryFixture('history-dev-layout', {nestedData: false}); + const dev = createHistoryFixture('history-dev-layout', { nestedData: false }); createReplicaHistory(dev.replicasDir, '1', { snapshots: [0, 187, 279], segments: [0, 188, 280], @@ -1173,108 +1846,276 @@ function assertHistoryDiscoversDevAndProductionLayoutsWithMultipleReplicas() { snapshots: [50, 99], segments: [0, 51, 100], }); - const devPlan = discoverHistoryPlan({dataDir: dev.dataDir}); - assertEqual(devPlan.replicas.length, 2, 'history 应逐 replica 计算安全边界。'); - assertEqual(devPlan.candidates.length, 7, '多 replica history 候选数量必须符合 snapshot/segment 边界。'); + const devPlan = discoverHistoryPlan({ dataDir: dev.dataDir }); + assertEqual( + devPlan.replicas.length, + 2, + 'history 应逐 replica 计算安全边界。', + ); + assertEqual( + devPlan.candidates.length, + 7, + '多 replica history 候选数量必须符合 snapshot/segment 边界。', + ); assertTrue( - devPlan.candidates.some(({path}) => path === 'replicas/1/clog/00000000000000000000.stdb.log'), + devPlan.candidates.some( + ({ path }) => path === 'replicas/1/clog/00000000000000000000.stdb.log', + ), 'dev 布局应识别边界 segment 之前的 commitlog。', ); assertTrue( - !devPlan.candidates.some(({path}) => path.includes('00000000000000000188.stdb.log')), + !devPlan.candidates.some(({ path }) => + path.includes('00000000000000000188.stdb.log'), + ), '跨越 latest snapshot 的边界 segment 必须保留。', ); assertTrue( - !devPlan.candidates.some(({path}) => path.includes('00000000000000000279.snapshot_dir')), + !devPlan.candidates.some(({ path }) => + path.includes('00000000000000000279.snapshot_dir'), + ), '每个 replica 的 latest snapshot 必须保留。', ); - const production = createHistoryFixture('history-production-layout', {nestedData: true}); + const production = createHistoryFixture('history-production-layout', { + nestedData: true, + }); createReplicaHistory(production.replicasDir, '7', { snapshots: [10, 20], segments: [0, 11, 21], }); - const productionPlan = discoverHistoryPlan({dataDir: production.dataDir}); - assertEqual(productionPlan.replicasDir, 'data/replicas', 'history 必须兼容 /stdb/data/replicas 布局。'); - assertEqual(productionPlan.candidates.length, 3, 'production 布局应识别一个旧 snapshot 与一对旧 commitlog 文件。'); + const productionPlan = discoverHistoryPlan({ dataDir: production.dataDir }); + assertEqual( + productionPlan.replicasDir, + 'data/replicas', + 'history 必须兼容 /stdb/data/replicas 布局。', + ); + assertEqual( + productionPlan.candidates.length, + 3, + 'production 布局应识别一个旧 snapshot 与一对旧 commitlog 文件。', + ); const importResult = runHistoryDryRun(dev); - assertStatus(importResult, 0, 'history dry-run 应能从已有 uploaded baseline manifest 导入 state。'); - assertTrue(existsSync(dev.statePath), 'history dry-run 应持久化导入后的 baseline state。'); - assertIncludes(importResult.stdout, 'history dry-run', 'history dry-run 应明确说明不会上传或删除。'); + assertStatus( + importResult, + 0, + 'history dry-run 应能从已有 uploaded baseline manifest 导入 state。', + ); + assertTrue( + existsSync(dev.statePath), + 'history dry-run 应持久化导入后的 baseline state。', + ); + assertIncludes( + importResult.stdout, + 'history dry-run', + 'history dry-run 应明确说明不会上传或删除。', + ); for (const candidate of devPlan.candidates) { - assertTrue(existsSync(path.join(dev.dataDir, candidate.path)), `history dry-run 不得删除候选: ${candidate.path}`); + assertTrue( + existsSync(path.join(dev.dataDir, candidate.path)), + `history dry-run 不得删除候选: ${candidate.path}`, + ); } } function assertHistorySkipsReplicaWithoutSnapshotAndRejectsMalformedNames() { - const noSnapshot = createHistoryFixture('history-no-snapshot', {nestedData: false}); - createReplicaHistory(noSnapshot.replicasDir, '1', {snapshots: [], segments: [0]}); - const plan = discoverHistoryPlan({dataDir: noSnapshot.dataDir}); - assertEqual(plan.candidates.length, 0, '没有 snapshot 的 replica 不得产生可删除候选。'); - assertEqual(plan.replicas[0]?.reason, 'no-snapshot', '没有 snapshot 时应记录明确跳过原因。'); + const noSnapshot = createHistoryFixture('history-no-snapshot', { + nestedData: false, + }); + createReplicaHistory(noSnapshot.replicasDir, '1', { + snapshots: [], + segments: [0], + }); + const plan = discoverHistoryPlan({ dataDir: noSnapshot.dataDir }); + assertEqual( + plan.candidates.length, + 0, + '没有 snapshot 的 replica 不得产生可删除候选。', + ); + assertEqual( + plan.replicas[0]?.reason, + 'no-snapshot', + '没有 snapshot 时应记录明确跳过原因。', + ); - const incompleteSnapshot = createHistoryFixture('history-incomplete-snapshot', {nestedData: false}); - const incompleteReplica = createReplicaHistory(incompleteSnapshot.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); - mkdirSync(path.join(incompleteReplica.snapshotsDir, '00000000000000000020.snapshot_dir')); - mkdirSync(path.join(incompleteReplica.snapshotsDir, '00000000000000000030.snapshot_dir')); - writeFileSync(path.join(incompleteReplica.snapshotsDir, '00000000000000000030.snapshot_dir', '00000000000000000030.snapshot_bsatn'), 'locked'); - writeFileSync(path.join(incompleteReplica.snapshotsDir, '00000000000000000030.lock'), `${process.pid}\n`); - const incompletePlan = discoverHistoryPlan({dataDir: incompleteSnapshot.dataDir}); - assertEqual(incompletePlan.replicas[0]?.latestSnapshot, '10', '缺少 snapshot_bsatn 或仍有 lockfile 的目录不得成为 latest snapshot。'); + const incompleteSnapshot = createHistoryFixture( + 'history-incomplete-snapshot', + { nestedData: false }, + ); + const incompleteReplica = createReplicaHistory( + incompleteSnapshot.replicasDir, + '1', + { snapshots: [0, 10], segments: [0, 1, 11] }, + ); + mkdirSync( + path.join( + incompleteReplica.snapshotsDir, + '00000000000000000020.snapshot_dir', + ), + ); + mkdirSync( + path.join( + incompleteReplica.snapshotsDir, + '00000000000000000030.snapshot_dir', + ), + ); + writeFileSync( + path.join( + incompleteReplica.snapshotsDir, + '00000000000000000030.snapshot_dir', + '00000000000000000030.snapshot_bsatn', + ), + 'locked', + ); + writeFileSync( + path.join(incompleteReplica.snapshotsDir, '00000000000000000030.lock'), + `${process.pid}\n`, + ); + const incompletePlan = discoverHistoryPlan({ + dataDir: incompleteSnapshot.dataDir, + }); + assertEqual( + incompletePlan.replicas[0]?.latestSnapshot, + '10', + '缺少 snapshot_bsatn 或仍有 lockfile 的目录不得成为 latest snapshot。', + ); assertTrue( - !incompletePlan.candidates.some(({path}) => path.includes('00000000000000000010.snapshot_dir')), + !incompletePlan.candidates.some(({ path }) => + path.includes('00000000000000000010.snapshot_dir'), + ), '最后一个完整且未锁定的 snapshot 必须保留。', ); - const malformedLog = createHistoryFixture('history-malformed-log', {nestedData: false}); - const malformedLogReplica = createReplicaHistory(malformedLog.replicasDir, '1', {snapshots: [10], segments: [0, 11]}); - writeFileSync(path.join(malformedLogReplica.clogDir, 'broken.stdb.log'), 'broken'); + const malformedLog = createHistoryFixture('history-malformed-log', { + nestedData: false, + }); + const malformedLogReplica = createReplicaHistory( + malformedLog.replicasDir, + '1', + { snapshots: [10], segments: [0, 11] }, + ); + writeFileSync( + path.join(malformedLogReplica.clogDir, 'broken.stdb.log'), + 'broken', + ); assertThrows( - () => discoverHistoryPlan({dataDir: malformedLog.dataDir}), + () => discoverHistoryPlan({ dataDir: malformedLog.dataDir }), 'commitlog 文件名不符合预期', '异常 commitlog 名称必须阻断整个清理计划。', ); } function assertHistoryRequiresBaselineAndProducesDeterministicDeferredBatch() { - const missingBaseline = createHistoryFixture('history-missing-baseline', {nestedData: false}); - createReplicaHistory(missingBaseline.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); - rmSync(missingBaseline.baselineManifestPath, {force: true}); - const missingResult = runHistoryCommand(missingBaseline, ['--dry-run'], {includeBaselineManifest: false}); - assertStatus(missingResult, 1, 'history 没有 baseline state 或 imported manifest 时必须失败。'); - assertIncludes(missingResult.stderr, '缺少已验真 baseline state', 'baseline 门禁失败应给出明确错误。'); + const missingBaseline = createHistoryFixture('history-missing-baseline', { + nestedData: false, + }); + createReplicaHistory(missingBaseline.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); + rmSync(missingBaseline.baselineManifestPath, { force: true }); + const missingResult = runHistoryCommand(missingBaseline, ['--dry-run'], { + includeBaselineManifest: false, + }); + assertStatus( + missingResult, + 1, + 'history 没有 baseline state 或 imported manifest 时必须失败。', + ); + assertIncludes( + missingResult.stderr, + '缺少已验真 baseline state', + 'baseline 门禁失败应给出明确错误。', + ); - const wrongKind = createHistoryFixture('history-wrong-baseline-kind', {nestedData: false}); - createReplicaHistory(wrongKind.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); - const wrongKindManifest = JSON.parse(readFileSync(wrongKind.baselineManifestPath, 'utf8')); + const wrongKind = createHistoryFixture('history-wrong-baseline-kind', { + nestedData: false, + }); + createReplicaHistory(wrongKind.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); + const wrongKindManifest = JSON.parse( + readFileSync(wrongKind.baselineManifestPath, 'utf8'), + ); wrongKindManifest.backupKind = 'spacetimedb-history'; - writeFileSync(wrongKind.baselineManifestPath, `${JSON.stringify(wrongKindManifest)}\n`); + writeFileSync( + wrongKind.baselineManifestPath, + `${JSON.stringify(wrongKindManifest)}\n`, + ); const wrongKindResult = runHistoryDryRun(wrongKind); - assertStatus(wrongKindResult, 1, 'history archive manifest 不得被导入为 full baseline。'); - assertIncludes(wrongKindResult.stderr, 'backupKind 必须是 spacetimedb-data-dir', 'baseline 类型不匹配应失败关闭。'); + assertStatus( + wrongKindResult, + 1, + 'history archive manifest 不得被导入为 full baseline。', + ); + assertIncludes( + wrongKindResult.stderr, + 'backupKind 必须是 spacetimedb-data-dir', + 'baseline 类型不匹配应失败关闭。', + ); - const deterministic = createHistoryFixture('history-deterministic-batch', {nestedData: false}); - createReplicaHistory(deterministic.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const deterministic = createHistoryFixture('history-deterministic-batch', { + nestedData: false, + }); + createReplicaHistory(deterministic.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); importHistoryState(deterministic); const firstResultFile = path.join(deterministic.workDir, 'defer-first.json'); - const secondResultFile = path.join(deterministic.workDir, 'defer-second.json'); - const first = runHistoryCommand(deterministic, ['--defer-upload', '--result-file', firstResultFile]); - const second = runHistoryCommand(deterministic, ['--defer-upload', '--result-file', secondResultFile]); + const secondResultFile = path.join( + deterministic.workDir, + 'defer-second.json', + ); + const first = runHistoryCommand(deterministic, [ + '--defer-upload', + '--result-file', + firstResultFile, + ]); + const second = runHistoryCommand(deterministic, [ + '--defer-upload', + '--result-file', + secondResultFile, + ]); assertStatus(first, 0, '第一次 history defer 应成功生成归档。'); - assertStatus(second, 0, '相同候选重复 history defer 应幂等复用 batch identity。'); + assertStatus( + second, + 0, + '相同候选重复 history defer 应幂等复用 batch identity。', + ); const firstPayload = JSON.parse(readFileSync(firstResultFile, 'utf8')); const secondPayload = JSON.parse(readFileSync(secondResultFile, 'utf8')); - assertEqual(firstPayload.batchId, secondPayload.batchId, '相同 baseline 与候选必须生成确定性 batchId。'); - assertEqual(firstPayload.objectKey, secondPayload.objectKey, '相同 batch 重跑不得制造新的 OSS object key。'); - const archiveListing = spawnSync('tar', ['-tzf', firstPayload.archivePath], {encoding: 'utf8'}); + assertEqual( + firstPayload.batchId, + secondPayload.batchId, + '相同 baseline 与候选必须生成确定性 batchId。', + ); + assertEqual( + firstPayload.objectKey, + secondPayload.objectKey, + '相同 batch 重跑不得制造新的 OSS object key。', + ); + const archiveListing = spawnSync('tar', ['-tzf', firstPayload.archivePath], { + encoding: 'utf8', + }); assertStatus(archiveListing, 0, 'history 归档应可被 tar 正常读取。'); - assertIncludes(archiveListing.stdout, path.basename(firstPayload.manifestPath), 'history 归档内部必须携带安全候选 manifest。'); + assertIncludes( + archiveListing.stdout, + path.basename(firstPayload.manifestPath), + 'history 归档内部必须携带安全候选 manifest。', + ); - const dryRunPending = createHistoryFixture('history-dry-run-pending-cleanup', {nestedData: false}); - createReplicaHistory(dryRunPending.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const dryRunPending = createHistoryFixture( + 'history-dry-run-pending-cleanup', + { nestedData: false }, + ); + createReplicaHistory(dryRunPending.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); const dryRunState = importHistoryState(dryRunPending); - const dryRunPlan = discoverHistoryPlan({dataDir: dryRunPending.dataDir}); + const dryRunPlan = discoverHistoryPlan({ dataDir: dryRunPending.dataDir }); dryRunState.batches.push({ batchId: 'pending-cleanup', objectKey: 'database-backups/test-db/history/pending-cleanup.tar.gz', @@ -1286,57 +2127,117 @@ function assertHistoryRequiresBaselineAndProducesDeterministicDeferredBatch() { }); writeFileSync(dryRunPending.statePath, `${JSON.stringify(dryRunState)}\n`); const pendingDryRunResult = runHistoryDryRun(dryRunPending); - assertStatus(pendingDryRunResult, 0, '存在待清理 uploaded batch 时 history dry-run 仍应只读成功。'); + assertStatus( + pendingDryRunResult, + 0, + '存在待清理 uploaded batch 时 history dry-run 仍应只读成功。', + ); for (const candidate of dryRunPlan.candidates) { - assertTrue(existsSync(path.join(dryRunPending.dataDir, candidate.path)), `history dry-run 不得恢复执行待清理 batch: ${candidate.path}`); + assertTrue( + existsSync(path.join(dryRunPending.dataDir, candidate.path)), + `history dry-run 不得恢复执行待清理 batch: ${candidate.path}`, + ); } } function assertHistoryBackupLockRejectsLiveAndStaleOwners() { - const liveOwner = createHistoryFixture('history-live-lock', {nestedData: false}); - createReplicaHistory(liveOwner.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const liveOwner = createHistoryFixture('history-live-lock', { + nestedData: false, + }); + createReplicaHistory(liveOwner.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); importHistoryState(liveOwner); const liveLockPath = path.join(liveOwner.workDir, 'test-db.backup.lock'); writeFileSync(liveLockPath, `${process.pid}\n`); const liveResult = runHistoryCommand(liveOwner, ['--defer-upload']); - assertStatus(liveResult, 1, '仍存活进程持有 backup lock 时必须拒绝并发备份。'); - assertIncludes(liveResult.stderr, '已有数据库备份进程持有锁', '并发备份失败应报告 lock owner pid。'); + assertStatus( + liveResult, + 1, + '仍存活进程持有 backup lock 时必须拒绝并发备份。', + ); + assertIncludes( + liveResult.stderr, + '已有数据库备份进程持有锁', + '并发备份失败应报告 lock owner pid。', + ); - const staleOwner = createHistoryFixture('history-stale-lock', {nestedData: false}); - createReplicaHistory(staleOwner.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const staleOwner = createHistoryFixture('history-stale-lock', { + nestedData: false, + }); + createReplicaHistory(staleOwner.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); importHistoryState(staleOwner); const staleLockPath = path.join(staleOwner.workDir, 'test-db.backup.lock'); writeFileSync(staleLockPath, '2147483647\n'); const staleResult = runHistoryCommand(staleOwner, ['--defer-upload']); - assertStatus(staleResult, 1, '失效 owner pid 的 backup lock 也必须失败关闭,避免并发抢锁。'); - assertIncludes(staleResult.stderr, '拒绝自动抢锁', '失效 backup lock 应要求人工核对 multipart 与进程。'); - assertTrue(existsSync(staleLockPath), '失效 backup lock 未经人工核对不得自动删除。'); + assertStatus( + staleResult, + 1, + '失效 owner pid 的 backup lock 也必须失败关闭,避免并发抢锁。', + ); + assertIncludes( + staleResult.stderr, + '拒绝自动抢锁', + '失效 backup lock 应要求人工核对 multipart 与进程。', + ); + assertTrue( + existsSync(staleLockPath), + '失效 backup lock 未经人工核对不得自动删除。', + ); } function assertHistoryStatDriftPreventsAnyCleanup() { - const fixture = createHistoryFixture('history-stat-drift', {nestedData: false}); - createReplicaHistory(fixture.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); - const plan = discoverHistoryPlan({dataDir: fixture.dataDir}); - const driftCandidate = plan.candidates.find(({kind}) => kind === 'commitlog'); - const untouchedCandidate = plan.candidates.find(({kind}) => kind === 'snapshot'); - writeFileSync(path.join(fixture.dataDir, driftCandidate.path), 'changed-after-plan'); + const fixture = createHistoryFixture('history-stat-drift', { + nestedData: false, + }); + createReplicaHistory(fixture.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); + const plan = discoverHistoryPlan({ dataDir: fixture.dataDir }); + const driftCandidate = plan.candidates.find( + ({ kind }) => kind === 'commitlog', + ); + const untouchedCandidate = plan.candidates.find( + ({ kind }) => kind === 'snapshot', + ); + writeFileSync( + path.join(fixture.dataDir, driftCandidate.path), + 'changed-after-plan', + ); assertThrows( - () => cleanupHistoryCandidates({dataDir: fixture.dataDir, candidates: plan.candidates}), + () => + cleanupHistoryCandidates({ + dataDir: fixture.dataDir, + candidates: plan.candidates, + }), 'stat 漂移', '任一候选 stat 漂移时必须在删除任何文件前失败。', ); - assertTrue(existsSync(path.join(fixture.dataDir, untouchedCandidate.path)), 'stat 漂移失败时不得删除其他候选。'); + assertTrue( + existsSync(path.join(fixture.dataDir, untouchedCandidate.path)), + 'stat 漂移失败时不得删除其他候选。', + ); } async function assertHistoryUploadFailureDoesNotDeleteSources() { - const fixture = createHistoryFixture('history-upload-failure', {nestedData: false}); - createReplicaHistory(fixture.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const fixture = createHistoryFixture('history-upload-failure', { + nestedData: false, + }); + createReplicaHistory(fixture.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); const state = importHistoryState(fixture); - const plan = discoverHistoryPlan({dataDir: fixture.dataDir}); + const plan = discoverHistoryPlan({ dataDir: fixture.dataDir }); const archivePath = path.join(fixture.workDir, 'history.tar.gz'); const manifestPath = `${archivePath}.manifest.json`; writeFileSync(archivePath, 'history archive'); - const manifest = createHistoryManifest({fixture, state, plan, archivePath}); + const manifest = createHistoryManifest({ fixture, state, plan, archivePath }); writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); let uploadError = null; @@ -1357,18 +2258,40 @@ async function assertHistoryUploadFailureDoesNotDeleteSources() { } catch (error) { uploadError = error; } - assertIncludes(uploadError?.message ?? '', 'synthetic upload failure', 'history 应保留上传失败原因。'); + assertIncludes( + uploadError?.message ?? '', + 'synthetic upload failure', + 'history 应保留上传失败原因。', + ); for (const candidate of plan.candidates) { - assertTrue(existsSync(path.join(fixture.dataDir, candidate.path)), `上传失败不得删除 history 源文件: ${candidate.path}`); + assertTrue( + existsSync(path.join(fixture.dataDir, candidate.path)), + `上传失败不得删除 history 源文件: ${candidate.path}`, + ); } const stateAfterFailure = JSON.parse(readFileSync(fixture.statePath, 'utf8')); - assertEqual(stateAfterFailure.batches.length, 0, '上传失败不得把 batch 标记为 uploaded。'); + assertEqual( + stateAfterFailure.batches.length, + 0, + '上传失败不得把 batch 标记为 uploaded。', + ); - const manifestFailure = createHistoryFixture('history-manifest-upload-failure', {nestedData: false}); - createReplicaHistory(manifestFailure.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const manifestFailure = createHistoryFixture( + 'history-manifest-upload-failure', + { nestedData: false }, + ); + createReplicaHistory(manifestFailure.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); const manifestFailureState = importHistoryState(manifestFailure); - const manifestFailurePlan = discoverHistoryPlan({dataDir: manifestFailure.dataDir}); - const manifestFailureArchive = path.join(manifestFailure.workDir, 'history.tar.gz'); + const manifestFailurePlan = discoverHistoryPlan({ + dataDir: manifestFailure.dataDir, + }); + const manifestFailureArchive = path.join( + manifestFailure.workDir, + 'history.tar.gz', + ); const manifestFailurePath = `${manifestFailureArchive}.manifest.json`; writeFileSync(manifestFailureArchive, 'history archive'); const manifestFailurePayload = createHistoryManifest({ @@ -1377,7 +2300,10 @@ async function assertHistoryUploadFailureDoesNotDeleteSources() { plan: manifestFailurePlan, archivePath: manifestFailureArchive, }); - writeFileSync(manifestFailurePath, `${JSON.stringify(manifestFailurePayload)}\n`); + writeFileSync( + manifestFailurePath, + `${JSON.stringify(manifestFailurePayload)}\n`, + ); let manifestUploadError = null; try { await uploadHistoryArchiveWithCleanup({ @@ -1400,21 +2326,33 @@ async function assertHistoryUploadFailureDoesNotDeleteSources() { } catch (error) { manifestUploadError = error; } - assertIncludes(manifestUploadError?.message ?? '', 'synthetic manifest upload failure', 'sidecar manifest 上传失败应阻断清理。'); + assertIncludes( + manifestUploadError?.message ?? '', + 'synthetic manifest upload failure', + 'sidecar manifest 上传失败应阻断清理。', + ); for (const candidate of manifestFailurePlan.candidates) { - assertTrue(existsSync(path.join(manifestFailure.dataDir, candidate.path)), `manifest 上传失败不得删除源文件: ${candidate.path}`); + assertTrue( + existsSync(path.join(manifestFailure.dataDir, candidate.path)), + `manifest 上传失败不得删除源文件: ${candidate.path}`, + ); } } async function assertHistorySuccessfulUploadCleansAndIsIdempotent() { - const fixture = createHistoryFixture('history-upload-success', {nestedData: false}); - createReplicaHistory(fixture.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const fixture = createHistoryFixture('history-upload-success', { + nestedData: false, + }); + createReplicaHistory(fixture.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); const state = importHistoryState(fixture); - const plan = discoverHistoryPlan({dataDir: fixture.dataDir}); + const plan = discoverHistoryPlan({ dataDir: fixture.dataDir }); const archivePath = path.join(fixture.workDir, 'history.tar.gz'); const manifestPath = `${archivePath}.manifest.json`; writeFileSync(archivePath, 'history archive'); - const manifest = createHistoryManifest({fixture, state, plan, archivePath}); + const manifest = createHistoryManifest({ fixture, state, plan, archivePath }); writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); let baselineVerifyCount = 0; const result = await uploadHistoryArchiveWithCleanup({ @@ -1434,7 +2372,7 @@ async function assertHistorySuccessfulUploadCleansAndIsIdempotent() { partSizeBytes: 102400, verifiedAt: '2026-07-16T00:10:00.000Z', }), - manifestUploadFn: async ({objectKey}) => ({ + manifestUploadFn: async ({ objectKey }) => ({ objectKey, contentLength: 512, archiveSha256: 'd'.repeat(64), @@ -1442,30 +2380,54 @@ async function assertHistorySuccessfulUploadCleansAndIsIdempotent() { }), verifyFn: async () => { baselineVerifyCount += 1; - return {verifiedAt: '2026-07-16T00:10:02.000Z'}; + return { verifiedAt: '2026-07-16T00:10:02.000Z' }; }, }); - assertEqual(baselineVerifyCount, 2, 'history 删除源文件前必须重新验真 full baseline 与 sidecar。'); - assertEqual(result.cleanup.deletedCount, plan.candidates.length, '验真上传成功后应删除全部安全候选。'); + assertEqual( + baselineVerifyCount, + 2, + 'history 删除源文件前必须重新验真 full baseline 与 sidecar。', + ); + assertEqual( + result.cleanup.deletedCount, + plan.candidates.length, + '验真上传成功后应删除全部安全候选。', + ); for (const candidate of plan.candidates) { - assertTrue(!existsSync(path.join(fixture.dataDir, candidate.path)), `验真成功后应删除 history 源文件: ${candidate.path}`); + assertTrue( + !existsSync(path.join(fixture.dataDir, candidate.path)), + `验真成功后应删除 history 源文件: ${candidate.path}`, + ); } - const repeatedCleanup = cleanupHistoryCandidates({dataDir: fixture.dataDir, candidates: plan.candidates}); - assertEqual(repeatedCleanup.alreadyMissingCount, plan.candidates.length, '重复清理同一 uploaded batch 应幂等。'); + const repeatedCleanup = cleanupHistoryCandidates({ + dataDir: fixture.dataDir, + candidates: plan.candidates, + }); + assertEqual( + repeatedCleanup.alreadyMissingCount, + plan.candidates.length, + '重复清理同一 uploaded batch 应幂等。', + ); } async function assertHistoryResumeReverifiesArchiveAndManifest() { - const fixture = createHistoryFixture('history-resume-verification', {nestedData: false}); - createReplicaHistory(fixture.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const fixture = createHistoryFixture('history-resume-verification', { + nestedData: false, + }); + createReplicaHistory(fixture.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); const state = importHistoryState(fixture); - const plan = discoverHistoryPlan({dataDir: fixture.dataDir}); + const plan = discoverHistoryPlan({ dataDir: fixture.dataDir }); state.batches.push({ batchId: 'resume-batch', objectKey: 'database-backups/test-db/history/resume.tar.gz', contentLength: 100, archiveSha256: 'e'.repeat(64), verifiedAt: '2026-07-16T00:30:00.000Z', - manifestObjectKey: 'database-backups/test-db/history/resume.tar.gz.manifest.json', + manifestObjectKey: + 'database-backups/test-db/history/resume.tar.gz.manifest.json', manifestContentLength: 200, manifestArchiveSha256: 'f'.repeat(64), manifestVerifiedAt: '2026-07-16T00:30:01.000Z', @@ -1480,21 +2442,36 @@ async function assertHistoryResumeReverifiesArchiveAndManifest() { state, dataDir: fixture.dataDir, verificationOptions: {}, - verifyFn: async ({objectKey}) => { + verifyFn: async ({ objectKey }) => { verifiedKeys.push(objectKey); - return {verifiedAt: '2026-07-16T00:31:00.000Z'}; + return { verifiedAt: '2026-07-16T00:31:00.000Z' }; }, }); - assertEqual(verifiedKeys.length, 2, '续清理前必须重新验真 history archive 与 sidecar manifest。'); + assertEqual( + verifiedKeys.length, + 2, + '续清理前必须重新验真 history archive 与 sidecar manifest。', + ); for (const candidate of plan.candidates) { - assertTrue(!existsSync(path.join(fixture.dataDir, candidate.path)), `续清理验真后应删除候选: ${candidate.path}`); + assertTrue( + !existsSync(path.join(fixture.dataDir, candidate.path)), + `续清理验真后应删除候选: ${candidate.path}`, + ); } - const failure = createHistoryFixture('history-resume-verification-failure', {nestedData: false}); - createReplicaHistory(failure.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const failure = createHistoryFixture('history-resume-verification-failure', { + nestedData: false, + }); + createReplicaHistory(failure.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); const failureState = importHistoryState(failure); - const failurePlan = discoverHistoryPlan({dataDir: failure.dataDir}); - failureState.batches.push({...state.batches[0], candidates: failurePlan.candidates}); + const failurePlan = discoverHistoryPlan({ dataDir: failure.dataDir }); + failureState.batches.push({ + ...state.batches[0], + candidates: failurePlan.candidates, + }); writeFileSync(failure.statePath, `${JSON.stringify(failureState)}\n`); let resumeError = null; try { @@ -1510,87 +2487,140 @@ async function assertHistoryResumeReverifiesArchiveAndManifest() { } catch (error) { resumeError = error; } - assertIncludes(resumeError?.message ?? '', 'synthetic resume HEAD failure', '续清理 OSS 复核失败应保留错误。'); + assertIncludes( + resumeError?.message ?? '', + 'synthetic resume HEAD failure', + '续清理 OSS 复核失败应保留错误。', + ); for (const candidate of failurePlan.candidates) { - assertTrue(existsSync(path.join(failure.dataDir, candidate.path)), `续清理验真失败不得删除候选: ${candidate.path}`); + assertTrue( + existsSync(path.join(failure.dataDir, candidate.path)), + `续清理验真失败不得删除候选: ${candidate.path}`, + ); } } -function createHistoryFixture(name, {nestedData}) { +function createHistoryFixture(name, { nestedData }) { const root = path.join(tmpRoot, name); const dataDir = path.join(root, 'stdb'); - const replicasDir = nestedData ? path.join(dataDir, 'data', 'replicas') : path.join(dataDir, 'replicas'); + const replicasDir = nestedData + ? path.join(dataDir, 'data', 'replicas') + : path.join(dataDir, 'replicas'); const workDir = path.join(root, 'work'); const statePath = path.join(workDir, 'history-state.json'); const baselineManifestPath = path.join(workDir, 'baseline.manifest.json'); - mkdirSync(replicasDir, {recursive: true}); - mkdirSync(workDir, {recursive: true}); - writeFileSync(baselineManifestPath, `${JSON.stringify({ - backupKind: 'spacetimedb-data-dir', - uploadStatus: 'uploaded', - database: 'test-db', + mkdirSync(replicasDir, { recursive: true }); + mkdirSync(workDir, { recursive: true }); + writeFileSync( + baselineManifestPath, + `${JSON.stringify( + { + backupKind: 'spacetimedb-data-dir', + uploadStatus: 'uploaded', + database: 'test-db', + dataDir, + bucket: 'backup-bucket', + objectKey: 'database-backups/test-db/baseline.tar.gz', + manifestObjectKey: + 'database-backups/test-db/baseline.tar.gz.manifest.json', + contentLength: 1234, + archiveSha256: 'a'.repeat(64), + manifestContentLength: 512, + manifestArchiveSha256: '9'.repeat(64), + manifestVerifiedAt: '2026-07-16T00:00:00.500Z', + verifiedAt: '2026-07-16T00:00:00.000Z', + uploadedAt: '2026-07-16T00:00:01.000Z', + }, + null, + 2, + )}\n`, + ); + return { + root, dataDir, - bucket: 'backup-bucket', - objectKey: 'database-backups/test-db/baseline.tar.gz', - manifestObjectKey: 'database-backups/test-db/baseline.tar.gz.manifest.json', - contentLength: 1234, - archiveSha256: 'a'.repeat(64), - manifestContentLength: 512, - manifestArchiveSha256: '9'.repeat(64), - manifestVerifiedAt: '2026-07-16T00:00:00.500Z', - verifiedAt: '2026-07-16T00:00:00.000Z', - uploadedAt: '2026-07-16T00:00:01.000Z', - }, null, 2)}\n`); - return {root, dataDir, replicasDir, workDir, statePath, baselineManifestPath}; + replicasDir, + workDir, + statePath, + baselineManifestPath, + }; } -function createReplicaHistory(replicasDir, replicaId, {snapshots, segments}) { +function createReplicaHistory(replicasDir, replicaId, { snapshots, segments }) { const replicaDir = path.join(replicasDir, replicaId); const snapshotsDir = path.join(replicaDir, 'snapshots'); const clogDir = path.join(replicaDir, 'clog'); - mkdirSync(snapshotsDir, {recursive: true}); - mkdirSync(clogDir, {recursive: true}); + mkdirSync(snapshotsDir, { recursive: true }); + mkdirSync(clogDir, { recursive: true }); for (const transaction of snapshots) { const name = `${String(transaction).padStart(20, '0')}.snapshot_dir`; const snapshotDir = path.join(snapshotsDir, name); - mkdirSync(path.join(snapshotDir, 'objects'), {recursive: true}); - writeFileSync(path.join(snapshotDir, `${String(transaction).padStart(20, '0')}.snapshot_bsatn`), `snapshot-${transaction}`); - writeFileSync(path.join(snapshotDir, 'objects', 'object.bin'), `object-${transaction}`); + mkdirSync(path.join(snapshotDir, 'objects'), { recursive: true }); + writeFileSync( + path.join( + snapshotDir, + `${String(transaction).padStart(20, '0')}.snapshot_bsatn`, + ), + `snapshot-${transaction}`, + ); + writeFileSync( + path.join(snapshotDir, 'objects', 'object.bin'), + `object-${transaction}`, + ); } for (const transaction of segments) { const prefix = String(transaction).padStart(20, '0'); - writeFileSync(path.join(clogDir, `${prefix}.stdb.log`), `log-${transaction}`); - writeFileSync(path.join(clogDir, `${prefix}.stdb.ofs`), `ofs-${transaction}`); + writeFileSync( + path.join(clogDir, `${prefix}.stdb.log`), + `log-${transaction}`, + ); + writeFileSync( + path.join(clogDir, `${prefix}.stdb.ofs`), + `ofs-${transaction}`, + ); } - return {replicaDir, snapshotsDir, clogDir}; + return { replicaDir, snapshotsDir, clogDir }; } function runHistoryDryRun(fixture) { const resultFile = path.join(fixture.workDir, 'dry-run-result.json'); - return runHistoryCommand(fixture, [ - '--result-file', resultFile, - '--dry-run', - ]); + return runHistoryCommand(fixture, ['--result-file', resultFile, '--dry-run']); } -function runHistoryCommand(fixture, extraArgs = [], {includeBaselineManifest = true} = {}) { +function runHistoryCommand( + fixture, + extraArgs = [], + { includeBaselineManifest = true } = {}, +) { const baselineManifestArgs = includeBaselineManifest ? ['--baseline-manifest', fixture.baselineManifestPath] : []; - return spawnSync(process.execPath, [ - BACKUP_SCRIPT, - '--mode', 'history', - '--data-dir', fixture.dataDir, - '--work-dir', fixture.workDir, - '--database', 'test-db', - '--bucket', 'backup-bucket', - '--endpoint', 'oss-cn-shanghai.aliyuncs.com', - '--access-key-id', 'test-access-key', - '--access-key-secret', 'test-access-secret', - '--baseline-state', fixture.statePath, - ...baselineManifestArgs, - ...extraArgs, - ], {encoding: 'utf8'}); + return spawnSync( + process.execPath, + [ + BACKUP_SCRIPT, + '--mode', + 'history', + '--data-dir', + fixture.dataDir, + '--work-dir', + fixture.workDir, + '--database', + 'test-db', + '--bucket', + 'backup-bucket', + '--endpoint', + 'oss-cn-shanghai.aliyuncs.com', + '--access-key-id', + 'test-access-key', + '--access-key-secret', + 'test-access-secret', + '--baseline-state', + fixture.statePath, + ...baselineManifestArgs, + ...extraArgs, + ], + { encoding: 'utf8' }, + ); } function importHistoryState(fixture) { @@ -1599,7 +2629,7 @@ function importHistoryState(fixture) { return JSON.parse(readFileSync(fixture.statePath, 'utf8')); } -function createHistoryManifest({fixture, state, plan, archivePath}) { +function createHistoryManifest({ fixture, state, plan, archivePath }) { return { schemaVersion: 1, backupKind: 'spacetimedb-history', @@ -1640,9 +2670,13 @@ function createFixture(name) { const workDir = path.join(root, 'work'); const systemctlLog = path.join(root, 'systemctl.log'); const tarLog = path.join(root, 'tar.log'); - mkdirSync(binDir, {recursive: true}); - mkdirSync(dataDir, {recursive: true}); - writeFileSync(path.join(dataDir, 'sample.bin'), 'sample backup payload\n', 'utf8'); + mkdirSync(binDir, { recursive: true }); + mkdirSync(dataDir, { recursive: true }); + writeFileSync( + path.join(dataDir, 'sample.bin'), + 'sample backup payload\n', + 'utf8', + ); writeExecutable( path.join(binDir, 'systemctl'), `#!/usr/bin/env bash @@ -1658,7 +2692,7 @@ echo 'fake tar failure' >&2 exit 2 `, ); - return {root, binDir, dataDir, workDir, systemctlLog, tarLog}; + return { root, binDir, dataDir, workDir, systemctlLog, tarLog }; } function runBackup(fixture, extraArgs = []) { @@ -1693,7 +2727,7 @@ function runBackup(fixture, extraArgs = []) { function writeExecutable(filePath, content) { writeFileSync(filePath, content, 'utf8'); - spawnSync('chmod', ['0755', filePath], {encoding: 'utf8'}); + spawnSync('chmod', ['0755', filePath], { encoding: 'utf8' }); } function readFile(filePath) { @@ -1717,7 +2751,9 @@ function assertIncludes(content, expected, reason) { function assertEqual(actual, expected, reason) { if (actual !== expected) { - failures.push(`${reason} 预期: ${String(expected)},实际: ${String(actual)}`); + failures.push( + `${reason} 预期: ${String(expected)},实际: ${String(actual)}`, + ); } } @@ -1749,7 +2785,9 @@ function assertThrows(callback, expectedMessage, reason) { function assertBufferEqual(actual, expected, reason) { if (!Buffer.isBuffer(actual) || !actual.equals(expected)) { - failures.push(`${reason} 预期 ${expected.length} bytes,实际 ${actual?.length ?? ''} bytes。`); + failures.push( + `${reason} 预期 ${expected.length} bytes,实际 ${actual?.length ?? ''} bytes。`, + ); } } diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index c567e8232..15fa3b1ad 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -821,6 +821,31 @@ const checks = [ reason: '生产冷备份 service 必须用 node -- 分隔脚本参数,避免 Node 22 抢占业务 --env-file。', }, + { + file: 'deploy/systemd/genarrative-database-backup.service', + includes: 'Environment=NODE_OPTIONS=--max-old-space-size=768', + reason: + '备份 Node 进程必须设置独立 heap 上限,避免目录扫描异常拖垮 release 主机。', + }, + { + file: 'deploy/systemd/genarrative-database-backup.service', + includes: + 'Environment=GENARRATIVE_DATABASE_BACKUP_STOP_MARKER=/var/lib/genarrative/database-backups/.spacetimedb-stopped', + reason: + '备份停库 marker 必须固定在受保护的 release work-dir,供 OOM 后 systemd 兜底恢复服务。', + }, + { + file: 'deploy/systemd/genarrative-database-backup.service', + includes: 'MemoryMax=1G', + reason: + '备份 service 必须设置 systemd 内存硬上限,避免异常进程消耗整机内存。', + }, + { + file: 'deploy/systemd/genarrative-database-backup.service', + includes: 'ExecStopPost=/bin/sh -c', + reason: + '备份主进程被 OOM kill 后必须由 systemd 兜底恢复停掉的 SpacetimeDB、API、worker 和 controller。', + }, { file: 'deploy/systemd/genarrative-database-backup.service', excludes: '--storage-format files', @@ -901,20 +926,61 @@ const checks = [ }, { file: 'jenkins/Jenkinsfile.production-server-provision', - excludes: - "params.DEPLOY_TARGET == 'release' && databaseBackupProfile == 'files-history'", + includes: + "string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.8.3'", reason: - 'release 必须能在显式选择 profile 且 baseline 预检通过后启用 files-history。', + 'Server-Provision Job 的 SpacetimeDB 下载根必须与 workspace 锁定的 2.8.3 版本一致。', + }, + { + file: 'jenkins/Jenkinsfile.production-server-provision', + includes: + "string(name: 'SPACETIME_EXPECTED_COMMIT', defaultValue: '8e410d2842147bd8e5a32a9589cc00c19f7478e2'", + reason: + 'Server-Provision Job 必须锁定 SpacetimeDB 2.8.3 的 CLI commit,拒绝同版本号但构建来源不一致的二进制。', + }, + { + file: 'jenkins/Jenkinsfile.production-server-provision', + includes: + 'SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.8.3}"', + reason: + 'Server-Provision 执行阶段的 SpacetimeDB 下载根默认值必须与 Job 参数默认值一致。', + }, + { + file: 'jenkins/Jenkinsfile.production-server-provision', + includes: + 'SPACETIME_EXPECTED_COMMIT="${SPACETIME_EXPECTED_COMMIT:-8e410d2842147bd8e5a32a9589cc00c19f7478e2}"', + reason: + 'Server-Provision 执行阶段的 SpacetimeDB commit 默认值必须与 Job 参数默认值一致。', + }, + { + file: 'jenkins/Jenkinsfile.production-server-provision', + excludes: 'v2.7.0-hotfix3', + reason: + 'Server-Provision 不得保留已退役的 SpacetimeDB 2.7.0 hotfix 资产标签。', + }, + { + file: 'jenkins/Jenkinsfile.production-server-provision', + excludes: 'd220349adb7af7eefa810eb08a185609356b83f6', + reason: + 'Server-Provision 不得保留 SpacetimeDB 2.7.0 的旧 CLI commit 门禁。', + }, + { + file: 'jenkins/Jenkinsfile.production-server-provision', + includes: 'release 仅允许 archive-full;files-history', + reason: + 'release 必须拒绝 files-history,避免逐文件 catalog 扫描再次触发生产内存峰值。', }, { file: 'scripts/database-backup-to-oss.mjs', includes: 'assertSufficientWorkDirSpace({dataDir, workDir, args, env})', + normalizeWhitespace: true, reason: '生产冷备份必须先做工作目录剩余空间预检,避免停库后写满磁盘。', }, { file: 'scripts/database-backup-to-oss.mjs', includes: - 'restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter})', + 'restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter, stopMarkerPath})', + normalizeWhitespace: true, reason: '生产冷备份打包失败时也必须恢复 SpacetimeDB 及依赖服务。', }, { @@ -7440,9 +7506,23 @@ const databaseTargetSourceStashes = [ let failed = false; +function includesGuardrail(content, check) { + if (!check.includes) { + return true; + } + if (!check.normalizeWhitespace) { + return content.includes(check.includes); + } + const normalizeSourceFragment = (value) => + value.replace(/\s+/gu, '').replace(/,([})])/gu, '$1'); + const normalizedContent = normalizeSourceFragment(content); + const normalizedExpected = normalizeSourceFragment(check.includes); + return normalizedContent.includes(normalizedExpected); +} + for (const check of checks) { const content = readFileSync(check.file, 'utf8'); - if (check.includes && !content.includes(check.includes)) { + if (!includesGuardrail(content, check)) { failed = true; console.error( `[check:production-ops] ${check.file} 缺少 ${check.includes}。${check.reason}`, diff --git a/scripts/check-server-provision-tools.sh b/scripts/check-server-provision-tools.sh index 69ab5c4aa..6e584d12a 100755 --- a/scripts/check-server-provision-tools.sh +++ b/scripts/check-server-provision-tools.sh @@ -39,13 +39,13 @@ cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" <<'EOF' #!/usr/bin/env bash cat <<'VERSION' spacetime Path: /tmp/spacetimedb-cli -Commit: d220349adb7af7eefa810eb08a185609356b83f6 -spacetimedb tool version 2.7.0; spacetimedb-lib version 2.7.0; +Commit: 8e410d2842147bd8e5a32a9589cc00c19f7478e2 +spacetimedb tool version 2.8.3; spacetimedb-lib version 2.8.3; VERSION EOF cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-standalone" <<'EOF' #!/usr/bin/env bash -echo "spacetimedb-standalone 2.7.0" +echo "spacetimedb-standalone 2.8.3" EOF chmod +x \ "${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" \ @@ -94,13 +94,13 @@ cat >"${BARE_TAG_ROOT_DIR}/bin/current/spacetimedb-cli" <<'EOF' #!/usr/bin/env bash cat <<'VERSION' spacetime Path: /tmp/spacetimedb-cli -Commit: a08663c7b94688a2542577532d472f751e641f5b -spacetimedb tool version 2.7.0; spacetimedb-lib version 2.7.0; +Commit: 0000000000000000000000000000000000000000 +spacetimedb tool version 2.8.3; spacetimedb-lib version 2.8.3; VERSION EOF cat >"${BARE_TAG_ROOT_DIR}/bin/current/spacetimedb-standalone" <<'EOF' #!/usr/bin/env bash -echo "spacetimedb-standalone 2.7.0" +echo "spacetimedb-standalone 2.8.3" EOF chmod +x \ "${BARE_TAG_ROOT_DIR}/bin/current/spacetimedb-cli" \ @@ -119,14 +119,14 @@ if ( "${REPO_ROOT}/scripts/prepare-server-provision-tools.sh" \ >"${BARE_TAG_LOG}" 2>&1 ); then - echo "[check-server-provision-tools] 裸 v2.7.0 tag 不应通过 hotfix3 复用门禁。" >&2 + echo "[check-server-provision-tools] commit 不匹配的 v2.8.3 不应通过复用门禁。" >&2 cat "${BARE_TAG_LOG}" >&2 exit 1 fi grep -q "SpacetimeDB commit 不匹配" "${BARE_TAG_LOG}" if grep -q "复用目标机已有 SpacetimeDB 安装" "${BARE_TAG_LOG}"; then - echo "[check-server-provision-tools] 裸 v2.7.0 tag 被错误复用。" >&2 + echo "[check-server-provision-tools] commit 不匹配的 v2.8.3 被错误复用。" >&2 cat "${BARE_TAG_LOG}" >&2 exit 1 fi @@ -157,7 +157,7 @@ if ( "${REPO_ROOT}/scripts/prepare-server-provision-tools.sh" \ >"${BARE_ARCHIVE_LOG}" 2>&1 ); then - echo "[check-server-provision-tools] 裸 v2.7.0 archive 不应通过 hotfix3 安装结果门禁。" >&2 + echo "[check-server-provision-tools] commit 不匹配的 v2.8.3 archive 不应通过安装结果门禁。" >&2 cat "${BARE_ARCHIVE_LOG}" >&2 exit 1 fi diff --git a/scripts/container-worker-smoke.mjs b/scripts/container-worker-smoke.mjs index 59715e5f8..881224570 100644 --- a/scripts/container-worker-smoke.mjs +++ b/scripts/container-worker-smoke.mjs @@ -1,4 +1,4 @@ -import {spawn} from 'node:child_process'; +import { spawn } from 'node:child_process'; import { chmodSync, copyFileSync, @@ -13,7 +13,11 @@ import path from 'node:path'; const [, , rawCommand = 'help', ...rawArgs] = process.argv; const projectRoot = process.cwd(); -const composeFile = path.join('deploy', 'container', 'docker-compose.loadtest.yml'); +const composeFile = path.join( + 'deploy', + 'container', + 'docker-compose.loadtest.yml', +); const smokeDir = path.join('deploy', 'container', 'worker-smoke'); const envPath = path.join(smokeDir, 'api-server.env'); const statePath = path.join(smokeDir, 'state.json'); @@ -22,13 +26,17 @@ const localImageDockerfilePath = path.join(localImageDir, 'Dockerfile.local'); const localImageBinaryPath = path.join(localImageDir, 'api-server'); const localCargoTargetDir = path.join('server-rs', 'target-worker-smoke'); const localSpacetimeImageDir = path.join(smokeDir, 'spacetimedb-image'); -const localSpacetimeDockerfilePath = path.join(localSpacetimeImageDir, 'Dockerfile.local'); +const localSpacetimeDockerfilePath = path.join( + localSpacetimeImageDir, + 'Dockerfile.local', +); const localSpacetimeBinaryPath = path.join(localSpacetimeImageDir, 'spacetime'); const localSpacetimeStandalonePath = path.join( localSpacetimeImageDir, 'spacetimedb-standalone', ); -const projectName = process.env.GENARRATIVE_WORKER_SMOKE_PROJECT || 'genarrative-worker-smoke'; +const projectName = + process.env.GENARRATIVE_WORKER_SMOKE_PROJECT || 'genarrative-worker-smoke'; const defaultDatabase = process.env.GENARRATIVE_WORKER_SMOKE_DATABASE || 'genarrative-worker-smoke'; @@ -68,7 +76,7 @@ async function main() { printHelp(false); return; case 'init': - await ensureStateAndEnv({force: rawArgs.includes('--force')}); + await ensureStateAndEnv({ force: rawArgs.includes('--force') }); return; case 'build': await ensureStateAndEnv(); @@ -99,7 +107,7 @@ async function main() { return; case 'api-update': await ensureStateAndEnv(); - await apiOnlyUpdate({build: rawArgs.includes('--build')}); + await apiOnlyUpdate({ build: rawArgs.includes('--build') }); return; case 'scale': await ensureStateAndEnv(); @@ -114,7 +122,7 @@ async function main() { await dockerCompose(['ps', ...rawArgs]); return; case 'down': - await ensureStateAndEnv({create: false}); + await ensureStateAndEnv({ create: false }); await dockerCompose(['down', ...rawArgs]); return; case 'smoke': @@ -128,9 +136,9 @@ async function main() { async function runSmoke() { if (rawArgs.includes('--force')) { await ensureStateAndEnv(); - await dockerComposeCapture(['down', '-v'], {allowFailure: true}); + await dockerComposeCapture(['down', '-v'], { allowFailure: true }); } - const state = await ensureStateAndEnv({force: rawArgs.includes('--force')}); + const state = await ensureStateAndEnv({ force: rawArgs.includes('--force') }); await assertSavedPortsAvailableForNewProject(state); console.log( `[worker-smoke] 使用隔离环境 project=${projectName} database=${state.database}`, @@ -147,10 +155,10 @@ async function runSmoke() { const beforeWorkerIds = await getContainerIds('external-generation-worker'); console.log(`[worker-smoke] worker 容器: ${beforeWorkerIds.join(', ')}`); - const firstJobId = await enqueueSmokeJob({label: 'before-api-update'}); + const firstJobId = await enqueueSmokeJob({ label: 'before-api-update' }); await waitForJobConsumed(firstJobId); - await apiOnlyUpdate({build: false}); + await apiOnlyUpdate({ build: false }); const afterWorkerIds = await getContainerIds('external-generation-worker'); if (beforeWorkerIds.join('\n') !== afterWorkerIds.join('\n')) { throw new Error( @@ -159,10 +167,12 @@ async function runSmoke() { } console.log('[worker-smoke] api-only 更新未重建 worker 容器。'); - const secondJobId = await enqueueSmokeJob({label: 'after-api-update'}); + const secondJobId = await enqueueSmokeJob({ label: 'after-api-update' }); await waitForJobConsumed(secondJobId); await printQueueStatus(); - console.log('[worker-smoke] smoke 通过:worker 独立消费队列,API-only 更新未停止 worker。'); + console.log( + '[worker-smoke] smoke 通过:worker 独立消费队列,API-only 更新未停止 worker。', + ); } async function buildRuntimeImages() { @@ -196,13 +206,19 @@ async function buildLocalBinaryRuntimeImages() { process.env.GENARRATIVE_WORKER_SMOKE_CARGO_PROFILE === 'release' ? 'release' : 'debug'; - const buildArgs = ['build', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml']; + const buildArgs = [ + 'build', + '-p', + 'api-server', + '--manifest-path', + 'server-rs/Cargo.toml', + ]; if (profile === 'release') { buildArgs.push('--release'); } const cargoImage = resolveLocalBinaryCargoImage(); const cargoHome = resolveLocalBinaryCargoHome(); - mkdirSync(cargoHome, {recursive: true}); + mkdirSync(cargoHome, { recursive: true }); console.log( `[worker-smoke] 使用 ${cargoImage} 复用本机 Cargo 缓存构建 ${profile} api-server 二进制。`, @@ -235,17 +251,27 @@ async function buildLocalBinaryRuntimeImages() { ...buildArgs, ]); - const sourceBinaryPath = path.join(localCargoTargetDir, profile, 'api-server'); + const sourceBinaryPath = path.join( + localCargoTargetDir, + profile, + 'api-server', + ); if (!existsSync(sourceBinaryPath)) { - throw new Error(`未找到 worker smoke api-server 二进制: ${sourceBinaryPath}`); + throw new Error( + `未找到 worker smoke api-server 二进制: ${sourceBinaryPath}`, + ); } - mkdirSync(localImageDir, {recursive: true}); + mkdirSync(localImageDir, { recursive: true }); copyFileSync(sourceBinaryPath, localImageBinaryPath); chmodSync(localImageBinaryPath, 0o755); const baseImage = await resolveLocalBinaryBaseImage(); - writeFileSync(localImageDockerfilePath, buildLocalBinaryDockerfile(baseImage), 'utf8'); + writeFileSync( + localImageDockerfilePath, + buildLocalBinaryDockerfile(baseImage), + 'utf8', + ); await run('docker', [ 'build', @@ -260,7 +286,9 @@ async function buildLocalBinaryRuntimeImages() { } function resolveLocalBinaryCargoImage() { - return process.env.GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE || 'rust:1.93-bookworm'; + return ( + process.env.GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE || 'rust:1.93-bookworm' + ); } function resolveLocalBinaryCargoHome() { @@ -274,42 +302,62 @@ function resolveLocalBinaryCargoHome() { } function currentUserSpec() { - if (typeof process.getuid === 'function' && typeof process.getgid === 'function') { + if ( + typeof process.getuid === 'function' && + typeof process.getgid === 'function' + ) { return `${process.getuid()}:${process.getgid()}`; } return '0:0'; } async function ensureSpacetimeImage() { - if (process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_IMAGE_MODE === 'official') { + if ( + process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_IMAGE_MODE === 'official' + ) { return; } const imageName = localSpacetimeImageName(); - const existingImage = await runCapture('docker', ['image', 'inspect', imageName], { - allowFailure: true, - quiet: true, - }); + const existingImage = await runCapture( + 'docker', + ['image', 'inspect', imageName], + { + allowFailure: true, + quiet: true, + }, + ); if (existingImage.code === 0 && !rawArgs.includes('--force')) { return; } const spacetimePath = await resolveSpacetimeBinaryPath(); if (!spacetimePath) { - throw new Error('未找到本机 spacetime CLI,无法构建隔离 SpacetimeDB 镜像。'); + throw new Error( + '未找到本机 spacetime CLI,无法构建隔离 SpacetimeDB 镜像。', + ); } - mkdirSync(localSpacetimeImageDir, {recursive: true}); + mkdirSync(localSpacetimeImageDir, { recursive: true }); copyFileSync(spacetimePath, localSpacetimeBinaryPath); chmodSync(localSpacetimeBinaryPath, 0o755); - const standalonePath = path.join(path.dirname(spacetimePath), 'spacetimedb-standalone'); + const standalonePath = path.join( + path.dirname(spacetimePath), + 'spacetimedb-standalone', + ); if (!existsSync(standalonePath)) { throw new Error(`未找到本机 spacetimedb-standalone: ${standalonePath}`); } copyFileSync(standalonePath, localSpacetimeStandalonePath); chmodSync(localSpacetimeStandalonePath, 0o755); - writeFileSync(localSpacetimeDockerfilePath, buildLocalSpacetimeDockerfile(), 'utf8'); + writeFileSync( + localSpacetimeDockerfilePath, + buildLocalSpacetimeDockerfile(), + 'utf8', + ); - console.log(`[worker-smoke] 使用本机 spacetime CLI 构建隔离镜像: ${imageName}`); + console.log( + `[worker-smoke] 使用本机 spacetime CLI 构建隔离镜像: ${imageName}`, + ); await run('docker', [ 'build', '-f', @@ -337,12 +385,14 @@ async function resolveSpacetimeBinaryPath() { if (process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_BIN) { return process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_BIN; } - const versionResult = await runCapture('spacetime', ['--version'], {quiet: true}); + const versionResult = await runCapture('spacetime', ['--version'], { + quiet: true, + }); const pathMatch = versionResult.stdout.match(/^spacetime Path:\s*(.+)$/mu); if (pathMatch?.[1]) { return pathMatch[1].trim(); } - const whichResult = await runCapture('which', ['spacetime'], {quiet: true}); + const whichResult = await runCapture('which', ['spacetime'], { quiet: true }); return whichResult.stdout.trim(); } @@ -387,11 +437,11 @@ async function upRuntime() { } async function ensureStateAndEnv(options = {}) { - const {force = false, create = true} = options; + const { force = false, create = true } = options; if (!create && !existsSync(statePath)) { return defaultState(); } - mkdirSync(smokeDir, {recursive: true}); + mkdirSync(smokeDir, { recursive: true }); if (!existsSync(statePath) || force) { const state = { @@ -419,7 +469,9 @@ async function ensureStateAndEnv(options = {}) { } console.log(`[worker-smoke] env=${envPath}`); console.log(`[worker-smoke] state=${statePath}`); - console.log(`[worker-smoke] SpacetimeDB=http://127.0.0.1:${state.spacetimePort}`); + console.log( + `[worker-smoke] SpacetimeDB=http://127.0.0.1:${state.spacetimePort}`, + ); console.log(`[worker-smoke] Nginx=http://127.0.0.1:${state.httpPort}`); return state; } @@ -552,7 +604,7 @@ async function enqueueSmokeJob(options = {}) { source_module: 'editor-canvas', source_entity_id: `worker-smoke-entity-${suffix}`, request_label: `worker-smoke ${label}`, - request_payload_json: JSON.stringify({label, suffix}), + request_payload_json: JSON.stringify({ label, suffix }), max_attempts: 1, available_at_micros: nowMicros, created_at_micros: nowMicros, @@ -574,7 +626,9 @@ async function enqueueSmokeJob(options = {}) { } async function printQueueStatus() { - console.log('[worker-smoke] external_generation_job 是 private table,status 显示最近 worker 日志:'); + console.log( + '[worker-smoke] external_generation_job 是 private table,status 显示最近 worker 日志:', + ); await printServiceLogs('external-generation-worker', 120); } @@ -584,17 +638,24 @@ async function waitForJobConsumed(jobId) { while (Date.now() < deadline) { const result = await dockerComposeCapture( ['logs', '--no-color', 'external-generation-worker'], - {allowFailure: true, quiet: true}, + { allowFailure: true, quiet: true }, ); lastOutput = `${result.stdout}\n${result.stderr}`; - if (lastOutput.includes(jobId) && lastOutput.includes('暂不支持的任务类型')) { - console.log(`[worker-smoke] job ${jobId} 已被 worker 领取并执行到 unsupported 分支。`); + if ( + lastOutput.includes(jobId) && + lastOutput.includes('暂不支持的任务类型') + ) { + console.log( + `[worker-smoke] job ${jobId} 已被 worker 领取并执行到 unsupported 分支。`, + ); return; } await sleep(1000); } await printServiceLogs('external-generation-worker', 120); - throw new Error(`等待 worker 消费 job ${jobId} 超时,最后输出:\n${lastOutput}`); + throw new Error( + `等待 worker 消费 job ${jobId} 超时,最后输出:\n${lastOutput}`, + ); } async function assertSavedPortsAvailableForNewProject(state) { @@ -634,7 +695,7 @@ async function getProjectContainerIds() { async function assertWorkersRunning() { const result = await dockerComposeCapture( ['ps', '--status', 'running', '-q', 'external-generation-worker'], - {allowFailure: true, quiet: true}, + { allowFailure: true, quiet: true }, ); const workerIds = result.stdout .split(/\r?\n/u) @@ -644,7 +705,9 @@ async function assertWorkersRunning() { return; } await printServiceLogs('external-generation-worker', 80); - throw new Error('external-generation-worker 未处于 running 状态,已输出最近日志。'); + throw new Error( + 'external-generation-worker 未处于 running 状态,已输出最近日志。', + ); } async function printServiceLogs(service, tail = 80) { @@ -663,8 +726,15 @@ async function waitForApi() { const deadline = Date.now() + 120_000; while (Date.now() < deadline) { const result = await dockerComposeCapture( - ['exec', '-T', 'api-server', 'curl', '-fsS', 'http://127.0.0.1:8082/healthz'], - {allowFailure: true, quiet: true}, + [ + 'exec', + '-T', + 'api-server', + 'curl', + '-fsS', + 'http://127.0.0.1:8082/healthz', + ], + { allowFailure: true, quiet: true }, ); if (result.code === 0) { console.log('[worker-smoke] api-server 已就绪: api-server:8082/healthz'); @@ -690,7 +760,7 @@ async function waitForHttp(url, label) { throw new Error(`${label} 等待超时: ${url}`); } -async function apiOnlyUpdate({build}) { +async function apiOnlyUpdate({ build }) { const beforeWorkerIds = await getContainerIds('external-generation-worker'); const args = ['up', '-d', '--no-deps', '--force-recreate']; if (build) { @@ -730,7 +800,7 @@ async function getContainerIds(service) { } async function dockerCompose(args) { - await run('docker', composeArgs(args), {env: composeEnv()}); + await run('docker', composeArgs(args), { env: composeEnv() }); } async function dockerComposeCapture(args, options = {}) { @@ -750,7 +820,8 @@ function composeEnv() { ...process.env, GENARRATIVE_CONTAINER_API_ENV_FILE: './worker-smoke/api-server.env', GENARRATIVE_CONTAINER_SPACETIME_IMAGE: - process.env.GENARRATIVE_CONTAINER_SPACETIME_IMAGE || localSpacetimeImageName(), + process.env.GENARRATIVE_CONTAINER_SPACETIME_IMAGE || + localSpacetimeImageName(), GENARRATIVE_CONTAINER_SPACETIME_PORT: String(state.spacetimePort), GENARRATIVE_CONTAINER_HTTP_PORT: String(state.httpPort), GENARRATIVE_CONTAINER_OTLP_GRPC_PORT: String(state.otlpGrpcPort), @@ -759,7 +830,7 @@ function composeEnv() { } function localSpacetimeImageName() { - return `${projectName}-spacetimedb:2.7.0`; + return `${projectName}-spacetimedb:2.8.3`; } function spacetimeServerUrl(state) { @@ -773,7 +844,9 @@ function sleep(ms) { async function run(commandName, args, options = {}) { const result = await runCapture(commandName, args, options); if (result.code !== 0 && !options.allowFailure) { - throw new Error(`${commandName} ${args.join(' ')} 失败,exit=${result.code}`); + throw new Error( + `${commandName} ${args.join(' ')} 失败,exit=${result.code}`, + ); } return result; } @@ -807,7 +880,7 @@ function runCapture(commandName, args, options = {}) { reject(new Error(`${commandName} 被信号终止: ${signal}`)); return; } - resolve({code: code ?? 0, stdout, stderr}); + resolve({ code: code ?? 0, stdout, stderr }); }); }); } diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs index d9a1ff9eb..c64e8657f 100644 --- a/scripts/database-backup-to-oss.mjs +++ b/scripts/database-backup-to-oss.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import {spawnSync} from 'node:child_process'; -import {createHash, createHmac} from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { createHash, createHmac } from 'node:crypto'; import { chmodSync, closeSync, @@ -21,20 +21,38 @@ import { symlinkSync, writeFileSync, } from 'node:fs'; -import {basename, dirname, isAbsolute, join, relative, resolve, sep} from 'node:path'; -import {Readable} from 'node:stream'; -import {pipeline} from 'node:stream/promises'; -import {setTimeout as sleep} from 'node:timers/promises'; -import {fileURLToPath} from 'node:url'; -import {gunzipSync, gzipSync} from 'node:zlib'; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { gunzipSync, gzipSync } from 'node:zlib'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const REPO_ROOT = resolve(__dirname, '..'); -const DEFAULT_LOCAL_DATA_DIR = resolve(REPO_ROOT, 'server-rs/.spacetimedb/local/data'); -const DEFAULT_LOCAL_WORK_DIR = resolve(REPO_ROOT, 'server-rs/.data/database-backups'); +const DEFAULT_LOCAL_DATA_DIR = resolve( + REPO_ROOT, + 'server-rs/.spacetimedb/local/data', +); +const DEFAULT_LOCAL_WORK_DIR = resolve( + REPO_ROOT, + 'server-rs/.data/database-backups', +); const DEFAULT_PRODUCTION_DATA_DIR = '/stdb'; const DEFAULT_PRODUCTION_WORK_DIR = '/var/lib/genarrative/database-backups'; +const DEFAULT_DATABASE_BACKUP_STOP_MARKER = join( + DEFAULT_PRODUCTION_WORK_DIR, + '.spacetimedb-stopped', +); const DEFAULT_SPACE_SAFETY_RATIO = 1.1; const DEFAULT_EXTRA_FREE_BYTES = 512 * 1024 * 1024; const OSS_ALGORITHM = 'OSS4-HMAC-SHA256'; @@ -117,7 +135,7 @@ function loadEnvFile(filePath, target, protectedKeys) { } function loadRepoEnv() { - const env = {...process.env}; + const env = { ...process.env }; const protectedKeys = new Set( Object.entries(process.env) .filter(([, value]) => String(value ?? '').trim()) @@ -289,21 +307,34 @@ function firstNonEmpty(...values) { } function parseDirectFilesConcurrency(rawValue) { - const value = Number(String(rawValue ?? DEFAULT_DIRECT_FILES_CONCURRENCY).trim()); - if (!Number.isSafeInteger(value) || value < 1 || value > MAX_DIRECT_FILES_CONCURRENCY) { - throw new Error(`GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY 必须是 1-${MAX_DIRECT_FILES_CONCURRENCY} 的整数,实际: ${rawValue}`); + const value = Number( + String(rawValue ?? DEFAULT_DIRECT_FILES_CONCURRENCY).trim(), + ); + if ( + !Number.isSafeInteger(value) || + value < 1 || + value > MAX_DIRECT_FILES_CONCURRENCY + ) { + throw new Error( + `GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY 必须是 1-${MAX_DIRECT_FILES_CONCURRENCY} 的整数,实际: ${rawValue}`, + ); } return value; } -export function createUploadBandwidthLimiter(rawValue, {nowFn = Date.now, sleepImpl = sleep} = {}) { +export function createUploadBandwidthLimiter( + rawValue, + { nowFn = Date.now, sleepImpl = sleep } = {}, +) { const normalized = String(rawValue ?? '').trim(); if (!normalized || normalized === '0') { return null; } const maxBytesPerSecond = Number(normalized); if (!Number.isSafeInteger(maxBytesPerSecond) || maxBytesPerSecond < 1024) { - throw new Error(`GENARRATIVE_DATABASE_BACKUP_UPLOAD_MAX_BYTES_PER_SECOND 必须为空、0 或 >= 1024 的整数,实际: ${rawValue}`); + throw new Error( + `GENARRATIVE_DATABASE_BACKUP_UPLOAD_MAX_BYTES_PER_SECOND 必须为空、0 或 >= 1024 的整数,实际: ${rawValue}`, + ); } let nextAvailableAtMs = 0; const waitForChunk = async (sizeBytes) => { @@ -319,22 +350,31 @@ export function createUploadBandwidthLimiter(rawValue, {nowFn = Date.now, sleepI return { maxBytesPerSecond, wrap(readable) { - return Readable.from((async function* throttleUpload() { - for await (const chunk of readable) { - await waitForChunk(chunk.length); - yield chunk; - } - })(), {objectMode: false}); + return Readable.from( + (async function* throttleUpload() { + for await (const chunk of readable) { + await waitForChunk(chunk.length); + yield chunk; + } + })(), + { objectMode: false }, + ); }, }; } function createBufferReadStream(buffer, chunkSizeBytes = 64 * 1024) { - return Readable.from((function* readChunks() { - for (let offset = 0; offset < buffer.length; offset += chunkSizeBytes) { - yield buffer.subarray(offset, Math.min(offset + chunkSizeBytes, buffer.length)); - } - })(), {objectMode: false}); + return Readable.from( + (function* readChunks() { + for (let offset = 0; offset < buffer.length; offset += chunkSizeBytes) { + yield buffer.subarray( + offset, + Math.min(offset + chunkSizeBytes, buffer.length), + ); + } + })(), + { objectMode: false }, + ); } function resolvePath(value) { @@ -363,9 +403,12 @@ function timestampForFile(date = new Date()) { return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`; } -function buildBackupNames({database, dataDir, objectPrefix}) { +function buildBackupNames({ database, dataDir, objectPrefix }) { const timestamp = timestampForFile(); - const databasePart = sanitizeObjectPart(database || basename(dataDir), 'spacetimedb'); + const databasePart = sanitizeObjectPart( + database || basename(dataDir), + 'spacetimedb', + ); const fileName = `${databasePart}-${timestamp}.tar.gz`; const prefix = String(objectPrefix || 'database-backups') .trim() @@ -375,24 +418,27 @@ function buildBackupNames({database, dataDir, objectPrefix}) { .map((part) => sanitizeObjectPart(part, 'backup')) .join('/'); const objectKey = [prefix, databasePart, fileName].filter(Boolean).join('/'); - return {fileName, objectKey}; + return { fileName, objectKey }; } function atomicWriteBuffer(filePath, body) { - mkdirSync(dirname(filePath), {recursive: true}); + mkdirSync(dirname(filePath), { recursive: true }); const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tempPath, body, {mode: 0o600}); + writeFileSync(tempPath, body, { mode: 0o600 }); chmodSync(tempPath, 0o600); renameSync(tempPath, filePath); } function atomicWriteJson(filePath, payload) { - atomicWriteBuffer(filePath, Buffer.from(`${JSON.stringify(payload, null, 2)}\n`, 'utf8')); + atomicWriteBuffer( + filePath, + Buffer.from(`${JSON.stringify(payload, null, 2)}\n`, 'utf8'), + ); } function atomicWriteGzipJson(filePath, payload) { const body = Buffer.from(`${JSON.stringify(payload)}\n`, 'utf8'); - atomicWriteBuffer(filePath, gzipSync(body, {level: 9})); + atomicWriteBuffer(filePath, gzipSync(body, { level: 9 })); } function processIsAlive(pid) { @@ -404,9 +450,12 @@ function processIsAlive(pid) { } } -function acquireBackupLock({workDir, database}) { - mkdirSync(workDir, {recursive: true}); - const lockPath = join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}.backup.lock`); +function acquireBackupLock({ workDir, database }) { + mkdirSync(workDir, { recursive: true }); + const lockPath = join( + workDir, + `${sanitizeObjectPart(database, 'spacetimedb')}.backup.lock`, + ); try { const fd = openSync(lockPath, 'wx', 0o600); writeFileSync(fd, `${process.pid}\n`, 'utf8'); @@ -415,7 +464,7 @@ function acquireBackupLock({workDir, database}) { try { const ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim()); if (ownerPid === process.pid) { - rmSync(lockPath, {force: true}); + rmSync(lockPath, { force: true }); } } catch { // The lock may already have been removed by the normal exit path. @@ -435,36 +484,53 @@ function acquireBackupLock({workDir, database}) { } } const ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim()); - if (Number.isSafeInteger(ownerPid) && ownerPid > 0 && processIsAlive(ownerPid)) { + if ( + Number.isSafeInteger(ownerPid) && + ownerPid > 0 && + processIsAlive(ownerPid) + ) { throw new Error(`已有数据库备份进程持有锁: ${lockPath} pid=${ownerPid}`); } - throw new Error(`发现失效数据库备份锁,拒绝自动抢锁;请核对 OSS multipart 与进程后手工删除: ${lockPath} pid=${ownerPid || ''}`); + throw new Error( + `发现失效数据库备份锁,拒绝自动抢锁;请核对 OSS multipart 与进程后手工删除: ${lockPath} pid=${ownerPid || ''}`, + ); } -function historyStatePath({args, env, workDir, database}) { - return resolvePath(firstNonEmpty( - args.baselineState, - env.GENARRATIVE_DATABASE_BACKUP_BASELINE_STATE, - join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-history-state.json`), - )); +function historyStatePath({ args, env, workDir, database }) { + return resolvePath( + firstNonEmpty( + args.baselineState, + env.GENARRATIVE_DATABASE_BACKUP_BASELINE_STATE, + join( + workDir, + `${sanitizeObjectPart(database, 'spacetimedb')}-history-state.json`, + ), + ), + ); } function baselineIdFor(baseline) { - return sha256Hex([ - baseline.bucket, - baseline.objectKey, - baseline.verifiedAt, - baseline.contentLength, - baseline.archiveSha256, - ].join('\0')).slice(0, 24); + return sha256Hex( + [ + baseline.bucket, + baseline.objectKey, + baseline.verifiedAt, + baseline.contentLength, + baseline.archiveSha256, + ].join('\0'), + ).slice(0, 24); } -function normalizeUploadedBaselineManifest(manifest, {database, dataDir}) { +function normalizeUploadedBaselineManifest(manifest, { database, dataDir }) { if (manifest.uploadStatus !== 'uploaded') { - throw new Error(`baseline manifest 必须是 uploaded,实际: ${manifest.uploadStatus ?? ''}`); + throw new Error( + `baseline manifest 必须是 uploaded,实际: ${manifest.uploadStatus ?? ''}`, + ); } if (manifest.backupKind !== 'spacetimedb-data-dir') { - throw new Error(`baseline manifest backupKind 必须是 spacetimedb-data-dir,实际: ${manifest.backupKind ?? ''}`); + throw new Error( + `baseline manifest backupKind 必须是 spacetimedb-data-dir,实际: ${manifest.backupKind ?? ''}`, + ); } const baseline = { backupKind: 'spacetimedb-data-dir', @@ -475,24 +541,28 @@ function normalizeUploadedBaselineManifest(manifest, {database, dataDir}) { verifiedAt: String(manifest.verifiedAt ?? '').trim(), uploadedAt: String(manifest.uploadedAt ?? '').trim(), contentLength: Number(manifest.contentLength), - archiveSha256: String(manifest.archiveSha256 ?? '').trim().toLowerCase(), + archiveSha256: String(manifest.archiveSha256 ?? '') + .trim() + .toLowerCase(), manifestObjectKey: String(manifest.manifestObjectKey ?? '').trim(), manifestContentLength: Number(manifest.manifestContentLength), - manifestArchiveSha256: String(manifest.manifestArchiveSha256 ?? '').trim().toLowerCase(), + manifestArchiveSha256: String(manifest.manifestArchiveSha256 ?? '') + .trim() + .toLowerCase(), manifestVerifiedAt: String(manifest.manifestVerifiedAt ?? '').trim(), }; if ( - !baseline.bucket - || !baseline.objectKey - || !baseline.verifiedAt - || !Number.isSafeInteger(baseline.contentLength) - || baseline.contentLength <= 0 - || !/^[a-f0-9]{64}$/u.test(baseline.archiveSha256) - || !baseline.manifestObjectKey - || !Number.isSafeInteger(baseline.manifestContentLength) - || baseline.manifestContentLength <= 0 - || !/^[a-f0-9]{64}$/u.test(baseline.manifestArchiveSha256) - || !baseline.manifestVerifiedAt + !baseline.bucket || + !baseline.objectKey || + !baseline.verifiedAt || + !Number.isSafeInteger(baseline.contentLength) || + baseline.contentLength <= 0 || + !/^[a-f0-9]{64}$/u.test(baseline.archiveSha256) || + !baseline.manifestObjectKey || + !Number.isSafeInteger(baseline.manifestContentLength) || + baseline.manifestContentLength <= 0 || + !/^[a-f0-9]{64}$/u.test(baseline.manifestArchiveSha256) || + !baseline.manifestVerifiedAt ) { throw new Error('baseline manifest 缺少已验真 OSS 归档或 sidecar 信息。'); } @@ -500,19 +570,23 @@ function normalizeUploadedBaselineManifest(manifest, {database, dataDir}) { return baseline; } -function validateHistoryState(state, {database, dataDir}) { +function validateHistoryState(state, { database, dataDir }) { if (state.schemaVersion !== HISTORY_STATE_SCHEMA_VERSION || !state.baseline) { throw new Error('history state schemaVersion 或 baseline 无效。'); } const baseline = normalizeUploadedBaselineManifest( - {...state.baseline, uploadStatus: 'uploaded'}, - {database, dataDir}, + { ...state.baseline, uploadStatus: 'uploaded' }, + { database, dataDir }, ); if (baseline.database !== database) { - throw new Error(`history state database 不匹配: expected=${database}, actual=${baseline.database}`); + throw new Error( + `history state database 不匹配: expected=${database}, actual=${baseline.database}`, + ); } if (resolvePath(baseline.dataDir) !== resolvePath(dataDir)) { - throw new Error(`history state dataDir 不匹配: expected=${resolvePath(dataDir)}, actual=${resolvePath(baseline.dataDir)}`); + throw new Error( + `history state dataDir 不匹配: expected=${resolvePath(dataDir)}, actual=${resolvePath(baseline.dataDir)}`, + ); } return { ...state, @@ -521,53 +595,82 @@ function validateHistoryState(state, {database, dataDir}) { }; } -function writeBaselineState({statePath, baseline, previousState = null}) { +function writeBaselineState({ statePath, baseline, previousState = null }) { const state = { schemaVersion: HISTORY_STATE_SCHEMA_VERSION, updatedAt: new Date().toISOString(), baseline, - batches: previousState?.baseline?.id === baseline.id && Array.isArray(previousState.batches) - ? previousState.batches - : [], + batches: + previousState?.baseline?.id === baseline.id && + Array.isArray(previousState.batches) + ? previousState.batches + : [], }; atomicWriteJson(statePath, state); return state; } -function loadOrImportHistoryState({args, env, statePath, database, dataDir}) { +function loadOrImportHistoryState({ args, env, statePath, database, dataDir }) { if (existsSync(statePath)) { - return validateHistoryState(readManifest(statePath), {database, dataDir}); + return validateHistoryState(readManifest(statePath), { database, dataDir }); } - const importPath = firstNonEmpty(args.baselineManifest, env.GENARRATIVE_DATABASE_BACKUP_BASELINE_MANIFEST); + const importPath = firstNonEmpty( + args.baselineManifest, + env.GENARRATIVE_DATABASE_BACKUP_BASELINE_MANIFEST, + ); if (!importPath) { - throw new Error(`history 模式缺少已验真 baseline state: ${statePath};可用 --baseline-manifest 导入已有 uploaded baseline manifest。`); + throw new Error( + `history 模式缺少已验真 baseline state: ${statePath};可用 --baseline-manifest 导入已有 uploaded baseline manifest。`, + ); } - const baseline = normalizeUploadedBaselineManifest(readManifest(resolvePath(importPath)), {database, dataDir}); - return writeBaselineState({statePath, baseline}); + const baseline = normalizeUploadedBaselineManifest( + readManifest(resolvePath(importPath)), + { database, dataDir }, + ); + return writeBaselineState({ statePath, baseline }); } function assertSafeRelativePath(dataDir, absolutePath) { - const relativePath = relative(resolvePath(dataDir), resolvePath(absolutePath)); - if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { + const relativePath = relative( + resolvePath(dataDir), + resolvePath(absolutePath), + ); + if ( + !relativePath || + relativePath === '..' || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { throw new Error(`history 候选路径越界或等于数据目录: ${absolutePath}`); } return relativePath.split(sep).join('/'); } function statFingerprint(absolutePath, rootPath = absolutePath) { - const entries = []; + // 候选 snapshot 可能包含数十万条目录项;增量更新摘要,避免把每条 + // fingerprint 字符串同时保存在 entries[] 后再 join,造成一次性内存峰值。 + const fingerprintHash = createHash('sha256'); + let isFirstEntry = true; + let entryCount = 0; let totalSize = 0n; const visit = (currentPath) => { - const stat = lstatSync(currentPath, {bigint: true}); + const stat = lstatSync(currentPath, { bigint: true }); if (stat.isSymbolicLink()) { throw new Error(`history 候选不得包含符号链接: ${currentPath}`); } - const entryPath = currentPath === rootPath ? '.' : relative(rootPath, currentPath).split(sep).join('/'); - const kind = stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'other'; + const entryPath = + currentPath === rootPath + ? '.' + : relative(rootPath, currentPath).split(sep).join('/'); + const kind = stat.isDirectory() + ? 'directory' + : stat.isFile() + ? 'file' + : 'other'; if (kind === 'other') { throw new Error(`history 候选只允许普通文件或目录: ${currentPath}`); } - entries.push([ + const entry = [ entryPath, kind, stat.dev.toString(), @@ -575,7 +678,13 @@ function statFingerprint(absolutePath, rootPath = absolutePath) { stat.mode.toString(), stat.size.toString(), stat.mtimeNs.toString(), - ].join('\0')); + ].join('\0'); + if (!isFirstEntry) { + fingerprintHash.update('\n'); + } + fingerprintHash.update(entry); + isFirstEntry = false; + entryCount += 1; if (stat.isFile()) { totalSize += stat.size; } else { @@ -586,22 +695,34 @@ function statFingerprint(absolutePath, rootPath = absolutePath) { }; visit(rootPath); return { - fingerprint: sha256Hex(entries.join('\n')), + fingerprint: fingerprintHash.digest('hex'), sizeBytes: totalSize.toString(), - entryCount: entries.length, + entryCount, }; } function findReplicasDir(dataDir) { - const candidates = [resolve(dataDir, 'replicas'), resolve(dataDir, 'data', 'replicas')] - .filter((candidate) => existsSync(candidate) && lstatSync(candidate).isDirectory()); + const candidates = [ + resolve(dataDir, 'replicas'), + resolve(dataDir, 'data', 'replicas'), + ].filter( + (candidate) => existsSync(candidate) && lstatSync(candidate).isDirectory(), + ); if (candidates.length !== 1) { - throw new Error(`无法唯一确定 replicas 目录: ${candidates.length === 0 ? '' : candidates.join(', ')}`); + throw new Error( + `无法唯一确定 replicas 目录: ${candidates.length === 0 ? '' : candidates.join(', ')}`, + ); } return candidates[0]; } -function historyCandidate({dataDir, absolutePath, kind, replicaId, transaction}) { +function historyCandidate({ + dataDir, + absolutePath, + kind, + replicaId, + transaction, +}) { const stat = statFingerprint(absolutePath); return { path: assertSafeRelativePath(dataDir, absolutePath), @@ -612,14 +733,16 @@ function historyCandidate({dataDir, absolutePath, kind, replicaId, transaction}) }; } -export function discoverHistoryPlan({dataDir}) { +export function discoverHistoryPlan({ dataDir }) { const resolvedDataDir = resolvePath(dataDir); const replicasDir = findReplicasDir(resolvedDataDir); - const replicaEntries = readdirSync(replicasDir, {withFileTypes: true}); + const replicaEntries = readdirSync(replicasDir, { withFileTypes: true }); const replicas = []; const candidates = []; - for (const replicaEntry of replicaEntries.sort((left, right) => left.name.localeCompare(right.name))) { + for (const replicaEntry of replicaEntries.sort((left, right) => + left.name.localeCompare(right.name), + )) { if (!replicaEntry.isDirectory()) { continue; } @@ -631,93 +754,133 @@ export function discoverHistoryPlan({dataDir}) { const snapshotsDir = join(replicaDir, 'snapshots'); const clogDir = join(replicaDir, 'clog'); if (!existsSync(snapshotsDir) || !lstatSync(snapshotsDir).isDirectory()) { - replicas.push({replicaId, status: 'skipped', reason: 'no-snapshots-directory'}); + replicas.push({ + replicaId, + status: 'skipped', + reason: 'no-snapshots-directory', + }); continue; } - const snapshotEntries = readdirSync(snapshotsDir, {withFileTypes: true}); - const snapshots = snapshotEntries.flatMap((entry) => { - const match = /^(\d{20})\.snapshot_dir$/u.exec(entry.name); - if (!match) { - return []; - } - const transaction = BigInt(match[1]); - if (transaction > 0xffff_ffff_ffff_ffffn) { - throw new Error(`snapshot transaction 超出 u64: ${entry.name}`); - } - if (!entry.isDirectory()) { - throw new Error(`snapshot 候选必须是目录: ${join(snapshotsDir, entry.name)}`); - } - const snapshotDir = join(snapshotsDir, entry.name); - const lockPath = join(snapshotsDir, `${match[1]}.lock`); - const snapshotFile = join(snapshotDir, `${match[1]}.snapshot_bsatn`); - if (existsSync(lockPath) || !existsSync(snapshotFile) || !lstatSync(snapshotFile).isFile()) { - return []; - } - return [{name: entry.name, transaction}]; - }).sort((left, right) => left.transaction < right.transaction ? -1 : left.transaction > right.transaction ? 1 : 0); + const snapshotEntries = readdirSync(snapshotsDir, { withFileTypes: true }); + const snapshots = snapshotEntries + .flatMap((entry) => { + const match = /^(\d{20})\.snapshot_dir$/u.exec(entry.name); + if (!match) { + return []; + } + const transaction = BigInt(match[1]); + if (transaction > 0xffff_ffff_ffff_ffffn) { + throw new Error(`snapshot transaction 超出 u64: ${entry.name}`); + } + if (!entry.isDirectory()) { + throw new Error( + `snapshot 候选必须是目录: ${join(snapshotsDir, entry.name)}`, + ); + } + const snapshotDir = join(snapshotsDir, entry.name); + const lockPath = join(snapshotsDir, `${match[1]}.lock`); + const snapshotFile = join(snapshotDir, `${match[1]}.snapshot_bsatn`); + if ( + existsSync(lockPath) || + !existsSync(snapshotFile) || + !lstatSync(snapshotFile).isFile() + ) { + return []; + } + return [{ name: entry.name, transaction }]; + }) + .sort((left, right) => + left.transaction < right.transaction + ? -1 + : left.transaction > right.transaction + ? 1 + : 0, + ); if (snapshots.length === 0) { - replicas.push({replicaId, status: 'skipped', reason: 'no-snapshot'}); + replicas.push({ replicaId, status: 'skipped', reason: 'no-snapshot' }); continue; } if (!existsSync(clogDir) || !lstatSync(clogDir).isDirectory()) { throw new Error(`replica ${replicaId} 缺少 clog 目录。`); } const segmentFiles = new Map(); - for (const entry of readdirSync(clogDir, {withFileTypes: true})) { + for (const entry of readdirSync(clogDir, { withFileTypes: true })) { const match = /^(\d{20})\.stdb\.(log|ofs)$/u.exec(entry.name); if (!match) { throw new Error(`commitlog 文件名不符合预期: ${entry.name}`); } if (!entry.isFile()) { - throw new Error(`commitlog 候选必须是普通文件: ${join(clogDir, entry.name)}`); + throw new Error( + `commitlog 候选必须是普通文件: ${join(clogDir, entry.name)}`, + ); } const transaction = BigInt(match[1]); if (transaction > 0xffff_ffff_ffff_ffffn) { throw new Error(`commitlog transaction 超出 u64: ${entry.name}`); } const key = transaction.toString(); - const group = segmentFiles.get(key) ?? {transaction}; + const group = segmentFiles.get(key) ?? { transaction }; group[match[2]] = entry.name; segmentFiles.set(key, group); } for (const group of segmentFiles.values()) { if (group.ofs && !group.log) { - throw new Error(`commitlog offset 缺少对应 log: replica=${replicaId}, transaction=${group.transaction}`); + throw new Error( + `commitlog offset 缺少对应 log: replica=${replicaId}, transaction=${group.transaction}`, + ); } } const segments = [...segmentFiles.values()] .filter((group) => group.log) - .sort((left, right) => left.transaction < right.transaction ? -1 : left.transaction > right.transaction ? 1 : 0); + .sort((left, right) => + left.transaction < right.transaction + ? -1 + : left.transaction > right.transaction + ? 1 + : 0, + ); const latestSnapshot = snapshots.at(-1).transaction; - const boundarySegment = segments.filter((segment) => segment.transaction <= latestSnapshot).at(-1); + const boundarySegment = segments + .filter((segment) => segment.transaction <= latestSnapshot) + .at(-1); if (!boundarySegment) { - throw new Error(`replica ${replicaId} 无法找到覆盖 latest snapshot ${latestSnapshot} 的 commitlog 边界。`); + throw new Error( + `replica ${replicaId} 无法找到覆盖 latest snapshot ${latestSnapshot} 的 commitlog 边界。`, + ); } for (const snapshot of snapshots.slice(0, -1)) { - candidates.push(historyCandidate({ - dataDir: resolvedDataDir, - absolutePath: join(snapshotsDir, snapshot.name), - kind: 'snapshot', - replicaId, - transaction: snapshot.transaction, - })); - } - for (const segment of segments.filter((item) => item.transaction < boundarySegment.transaction)) { - candidates.push(historyCandidate({ - dataDir: resolvedDataDir, - absolutePath: join(clogDir, segment.log), - kind: 'commitlog', - replicaId, - transaction: segment.transaction, - })); - if (segment.ofs) { - candidates.push(historyCandidate({ + candidates.push( + historyCandidate({ dataDir: resolvedDataDir, - absolutePath: join(clogDir, segment.ofs), - kind: 'commitlog-offset', + absolutePath: join(snapshotsDir, snapshot.name), + kind: 'snapshot', + replicaId, + transaction: snapshot.transaction, + }), + ); + } + for (const segment of segments.filter( + (item) => item.transaction < boundarySegment.transaction, + )) { + candidates.push( + historyCandidate({ + dataDir: resolvedDataDir, + absolutePath: join(clogDir, segment.log), + kind: 'commitlog', replicaId, transaction: segment.transaction, - })); + }), + ); + if (segment.ofs) { + candidates.push( + historyCandidate({ + dataDir: resolvedDataDir, + absolutePath: join(clogDir, segment.ofs), + kind: 'commitlog-offset', + replicaId, + transaction: segment.transaction, + }), + ); } } replicas.push({ @@ -733,7 +896,9 @@ export function discoverHistoryPlan({dataDir}) { replicasDir: assertSafeRelativePath(resolvedDataDir, replicasDir), replicas, candidates, - totalSizeBytes: candidates.reduce((sum, item) => sum + BigInt(item.sizeBytes), 0n).toString(), + totalSizeBytes: candidates + .reduce((sum, item) => sum + BigInt(item.sizeBytes), 0n) + .toString(), }; } @@ -762,7 +927,9 @@ function parseByteSize(rawValue, label) { } const match = /^(\d+)(?:\s*([KMGTPE]?)(?:I?B?)?)?$/iu.exec(value); if (!match) { - throw new Error(`${label} 必须是字节数或 K/M/G/T/P/E 后缀大小,实际: ${rawValue}`); + throw new Error( + `${label} 必须是字节数或 K/M/G/T/P/E 后缀大小,实际: ${rawValue}`, + ); } const [, amountText, unitText = ''] = match; const multipliers = { @@ -779,11 +946,11 @@ function parseByteSize(rawValue, label) { function formatBytes(bytes) { const value = BigInt(bytes); - const gib = Number(value) / (1024 ** 3); + const gib = Number(value) / 1024 ** 3; if (gib >= 1) { return `${gib.toFixed(1)}GiB`; } - const mib = Number(value) / (1024 ** 2); + const mib = Number(value) / 1024 ** 2; if (mib >= 1) { return `${mib.toFixed(1)}MiB`; } @@ -792,7 +959,9 @@ function formatBytes(bytes) { function getDirectorySizeBytes(dataDir) { const result = runCommand('du', ['-sk', dataDir]); - const [sizeKbText] = String(result.stdout ?? '').trim().split(/\s+/u); + const [sizeKbText] = String(result.stdout ?? '') + .trim() + .split(/\s+/u); if (!sizeKbText || !/^\d+$/u.test(sizeKbText)) { throw new Error(`无法解析数据目录大小: ${result.stdout}`); } @@ -800,7 +969,7 @@ function getDirectorySizeBytes(dataDir) { } function getAvailableBytes(fileSystemPath) { - const stat = statfsSync(fileSystemPath, {bigint: true}); + const stat = statfsSync(fileSystemPath, { bigint: true }); return stat.bavail * stat.bsize; } @@ -811,35 +980,51 @@ function parseSafetyRatio(rawValue) { } const ratio = Number(value); if (!Number.isFinite(ratio) || ratio < 1) { - throw new Error(`GENARRATIVE_DATABASE_BACKUP_SPACE_SAFETY_RATIO 必须是 >= 1 的数字,实际: ${rawValue}`); + throw new Error( + `GENARRATIVE_DATABASE_BACKUP_SPACE_SAFETY_RATIO 必须是 >= 1 的数字,实际: ${rawValue}`, + ); } return ratio; } -function calculateRequiredFreeBytes({dataSizeBytes, args, env}) { +function calculateRequiredFreeBytes({ dataSizeBytes, args, env }) { const explicitMinFreeBytes = parseByteSize( - firstNonEmpty(args.minFreeBytes, env.GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES), + firstNonEmpty( + args.minFreeBytes, + env.GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES, + ), 'GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES', ); if (explicitMinFreeBytes !== null) { return explicitMinFreeBytes; } - const ratio = parseSafetyRatio(env.GENARRATIVE_DATABASE_BACKUP_SPACE_SAFETY_RATIO); + const ratio = parseSafetyRatio( + env.GENARRATIVE_DATABASE_BACKUP_SPACE_SAFETY_RATIO, + ); const ratioBasisPoints = BigInt(Math.ceil(ratio * 10000)); const ratioRequirement = (dataSizeBytes * ratioBasisPoints + 9999n) / 10000n; const extraFreeBytes = parseByteSize( - firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_EXTRA_FREE_BYTES, String(DEFAULT_EXTRA_FREE_BYTES)), + firstNonEmpty( + env.GENARRATIVE_DATABASE_BACKUP_EXTRA_FREE_BYTES, + String(DEFAULT_EXTRA_FREE_BYTES), + ), 'GENARRATIVE_DATABASE_BACKUP_EXTRA_FREE_BYTES', ); const extraRequirement = dataSizeBytes + extraFreeBytes; - return ratioRequirement > extraRequirement ? ratioRequirement : extraRequirement; + return ratioRequirement > extraRequirement + ? ratioRequirement + : extraRequirement; } -function assertSufficientWorkDirSpace({dataDir, workDir, args, env}) { - mkdirSync(workDir, {recursive: true}); +function assertSufficientWorkDirSpace({ dataDir, workDir, args, env }) { + mkdirSync(workDir, { recursive: true }); const dataSizeBytes = getDirectorySizeBytes(dataDir); const availableBytes = getAvailableBytes(workDir); - const requiredFreeBytes = calculateRequiredFreeBytes({dataSizeBytes, args, env}); + const requiredFreeBytes = calculateRequiredFreeBytes({ + dataSizeBytes, + args, + env, + }); console.log( `[database-backup] 备份空间预检: data=${formatBytes(dataSizeBytes)}, available=${formatBytes(availableBytes)}, required=${formatBytes(requiredFreeBytes)}`, ); @@ -857,19 +1042,30 @@ function assertSufficientWorkDirSpace({dataDir, workDir, args, env}) { } } -function assertSufficientHistoryWorkDirSpace({historySizeBytes, workDir, args, env}) { - mkdirSync(workDir, {recursive: true}); +function assertSufficientHistoryWorkDirSpace({ + historySizeBytes, + workDir, + args, + env, +}) { + mkdirSync(workDir, { recursive: true }); const availableBytes = getAvailableBytes(workDir); - const requiredFreeBytes = calculateRequiredFreeBytes({dataSizeBytes: BigInt(historySizeBytes), args, env}); + const requiredFreeBytes = calculateRequiredFreeBytes({ + dataSizeBytes: BigInt(historySizeBytes), + args, + env, + }); console.log( `[database-backup] history 空间预检: candidates=${formatBytes(historySizeBytes)}, available=${formatBytes(availableBytes)}, required=${formatBytes(requiredFreeBytes)}`, ); if (availableBytes < requiredFreeBytes) { - throw new Error(`history 工作目录剩余空间不足: available=${formatBytes(availableBytes)};required=${formatBytes(requiredFreeBytes)}`); + throw new Error( + `history 工作目录剩余空间不足: available=${formatBytes(availableBytes)};required=${formatBytes(requiredFreeBytes)}`, + ); } } -function collectRestartServicesAfterBackup({args, env}) { +function collectRestartServicesAfterBackup({ args, env }) { const serviceNames = [ ...String(env.GENARRATIVE_DATABASE_BACKUP_RESTART_SERVICE_AFTER ?? '') .split(',') @@ -880,12 +1076,40 @@ function collectRestartServicesAfterBackup({args, env}) { return [...new Set(serviceNames.filter(Boolean))]; } -function stopServiceIfNeeded(serviceName) { +function databaseBackupStopMarkerPath(workDir) { + return resolvePath( + firstNonEmpty( + process.env.GENARRATIVE_DATABASE_BACKUP_STOP_MARKER, + workDir === DEFAULT_PRODUCTION_WORK_DIR + ? DEFAULT_DATABASE_BACKUP_STOP_MARKER + : join(workDir, '.spacetimedb-stopped'), + ), + ); +} + +function writeDatabaseBackupStopMarker(markerPath, serviceName) { + atomicWriteJson(markerPath, { + serviceName, + pid: process.pid, + stoppedAt: new Date().toISOString(), + }); +} + +function clearDatabaseBackupStopMarker(markerPath) { + if (markerPath) { + rmSync(markerPath, { force: true }); + } +} + +function stopServiceIfNeeded(serviceName, stopMarkerPath) { if (!serviceName) { return false; } console.log(`[database-backup] 停止服务以获取冷备份: ${serviceName}`); - runCommand('systemctl', ['stop', serviceName], {stdio: 'inherit'}); + writeDatabaseBackupStopMarker(stopMarkerPath, serviceName); + // stop 命令失败时仍保留 marker:systemd 的 ExecStopPost 需要它判断是否要 + // 兜底恢复,不能因为当前进程还能捕获异常就抹掉上一次停库证据。 + runCommand('systemctl', ['stop', serviceName], { stdio: 'inherit' }); return true; } @@ -894,7 +1118,7 @@ function startServiceIfNeeded(serviceName, wasStopped) { return; } console.log(`[database-backup] 恢复服务: ${serviceName}`); - runCommand('systemctl', ['start', serviceName], {stdio: 'inherit'}); + runCommand('systemctl', ['start', serviceName], { stdio: 'inherit' }); } function restartServicesAfterBackup(serviceNames) { @@ -905,17 +1129,25 @@ function restartServicesAfterBackup(serviceNames) { } console.log(`[database-backup] 冷备份后重启依赖服务: ${serviceName}`); try { - runCommand('systemctl', ['restart', serviceName], {stdio: 'inherit'}); + runCommand('systemctl', ['restart', serviceName], { stdio: 'inherit' }); } catch (error) { errors.push(error); } } if (errors.length > 0) { - throw new AggregateError(errors, `冷备份后重启依赖服务失败: ${errors.map((error) => error.message).join('; ')}`); + throw new AggregateError( + errors, + `冷备份后重启依赖服务失败: ${errors.map((error) => error.message).join('; ')}`, + ); } } -function restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter}) { +function restoreServicesAfterBackup({ + stopService, + serviceStopped, + restartServicesAfter, + stopMarkerPath, +}) { const errors = []; try { startServiceIfNeeded(stopService, serviceStopped); @@ -928,11 +1160,15 @@ function restoreServicesAfterBackup({stopService, serviceStopped, restartService errors.push(error); } if (errors.length > 0) { - throw new AggregateError(errors, `恢复冷备份相关服务失败: ${errors.map((error) => error.message).join('; ')}`); + throw new AggregateError( + errors, + `恢复冷备份相关服务失败: ${errors.map((error) => error.message).join('; ')}`, + ); } + clearDatabaseBackupStopMarker(stopMarkerPath); } -function createArchive({dataDir, workDir, fileName}) { +function createArchive({ dataDir, workDir, fileName }) { if (!existsSync(dataDir)) { throw new Error(`数据库数据目录不存在: ${dataDir}`); } @@ -940,32 +1176,38 @@ function createArchive({dataDir, workDir, fileName}) { if (!stat.isDirectory()) { throw new Error(`数据库数据路径不是目录: ${dataDir}`); } - mkdirSync(workDir, {recursive: true}); + mkdirSync(workDir, { recursive: true }); const archivePath = resolve(workDir, fileName); const parentDir = dirname(dataDir); const entryName = basename(dataDir); console.log(`[database-backup] 打包: ${dataDir} -> ${archivePath}`); - runCommand('tar', ['-czf', archivePath, '-C', parentDir, entryName], {stdio: 'inherit'}); + runCommand('tar', ['-czf', archivePath, '-C', parentDir, entryName], { + stdio: 'inherit', + }); verifyArchive(archivePath); return archivePath; } function verifyArchive(archivePath) { console.log(`[database-backup] 校验归档: ${archivePath}`); - runCommand('tar', ['-tzf', archivePath], {stdio: 'ignore'}); + runCommand('tar', ['-tzf', archivePath], { stdio: 'ignore' }); } -function historyBatchId({baselineId, plan}) { - const identity = plan.candidates.map((candidate) => [ - candidate.path, - candidate.kind, - candidate.transaction, - candidate.fingerprint, - ].join('\0')).join('\n'); +function historyBatchId({ baselineId, plan }) { + const identity = plan.candidates + .map((candidate) => + [ + candidate.path, + candidate.kind, + candidate.transaction, + candidate.fingerprint, + ].join('\0'), + ) + .join('\n'); return sha256Hex(`${baselineId}\0${identity}`).slice(0, 32); } -function buildHistoryNames({database, objectPrefix, baselineId, batchId}) { +function buildHistoryNames({ database, objectPrefix, baselineId, batchId }) { const databasePart = sanitizeObjectPart(database, 'spacetimedb'); const prefix = String(objectPrefix || 'database-backups') .trim() @@ -977,30 +1219,52 @@ function buildHistoryNames({database, objectPrefix, baselineId, batchId}) { const fileName = `${databasePart}-history-${batchId}.tar.gz`; return { fileName, - objectKey: [prefix, databasePart, 'history', baselineId, fileName].filter(Boolean).join('/'), + objectKey: [prefix, databasePart, 'history', baselineId, fileName] + .filter(Boolean) + .join('/'), }; } -function createHistoryArchive({dataDir, workDir, fileName, manifestPath, candidates}) { - mkdirSync(workDir, {recursive: true}); +function createHistoryArchive({ + dataDir, + workDir, + fileName, + manifestPath, + candidates, +}) { + mkdirSync(workDir, { recursive: true }); const archivePath = resolve(workDir, fileName); const candidatePaths = candidates.map((candidate) => candidate.path); - console.log(`[database-backup] 打包 history: ${candidatePaths.length} 个候选 -> ${archivePath}`); - runCommand('tar', [ - '-czf', - archivePath, - '-C', - dataDir, - ...candidatePaths, - '-C', - dirname(manifestPath), - basename(manifestPath), - ], {stdio: 'inherit'}); + console.log( + `[database-backup] 打包 history: ${candidatePaths.length} 个候选 -> ${archivePath}`, + ); + runCommand( + 'tar', + [ + '-czf', + archivePath, + '-C', + dataDir, + ...candidatePaths, + '-C', + dirname(manifestPath), + basename(manifestPath), + ], + { stdio: 'inherit' }, + ); verifyArchive(archivePath); return archivePath; } -function recordHistoryBatch({statePath, state, manifest, uploadResult, manifestUpload, status, cleanedAt = ''}) { +function recordHistoryBatch({ + statePath, + state, + manifest, + uploadResult, + manifestUpload, + status, + cleanedAt = '', +}) { const batch = { batchId: manifest.batchId, objectKey: uploadResult.objectKey, @@ -1016,9 +1280,11 @@ function recordHistoryBatch({statePath, state, manifest, uploadResult, manifestU cleanedAt, candidates: manifest.candidates, }; - const batches = state.batches.filter((item) => item.batchId !== batch.batchId); + const batches = state.batches.filter( + (item) => item.batchId !== batch.batchId, + ); batches.push(batch); - const nextState = {...state, updatedAt: new Date().toISOString(), batches}; + const nextState = { ...state, updatedAt: new Date().toISOString(), batches }; atomicWriteJson(statePath, nextState); return nextState; } @@ -1027,9 +1293,14 @@ function candidateKey(candidate) { return `${candidate.kind}\0${candidate.path}`; } -export function cleanupHistoryCandidates({dataDir, candidates}) { - const currentPlan = discoverHistoryPlan({dataDir}); - const eligible = new Map(currentPlan.candidates.map((candidate) => [candidateKey(candidate), candidate])); +export function cleanupHistoryCandidates({ dataDir, candidates }) { + const currentPlan = discoverHistoryPlan({ dataDir }); + const eligible = new Map( + currentPlan.candidates.map((candidate) => [ + candidateKey(candidate), + candidate, + ]), + ); const existing = []; for (const candidate of candidates) { const absolutePath = resolve(dataDir, candidate.path); @@ -1039,27 +1310,41 @@ export function cleanupHistoryCandidates({dataDir, candidates}) { } const current = eligible.get(candidateKey(candidate)); if (!current) { - throw new Error(`history 候选已不在当前安全边界内,拒绝删除: ${candidate.path}`); + throw new Error( + `history 候选已不在当前安全边界内,拒绝删除: ${candidate.path}`, + ); } const currentStat = statFingerprint(absolutePath); - if (currentStat.fingerprint !== candidate.fingerprint || currentStat.sizeBytes !== candidate.sizeBytes) { + if ( + currentStat.fingerprint !== candidate.fingerprint || + currentStat.sizeBytes !== candidate.sizeBytes + ) { throw new Error(`history 候选 stat 漂移,拒绝删除: ${candidate.path}`); } - existing.push({candidate, absolutePath}); + existing.push({ candidate, absolutePath }); } existing.sort((left, right) => { - const priority = {'commitlog-offset': 0, commitlog: 1, snapshot: 2}; - return (priority[left.candidate.kind] ?? 3) - (priority[right.candidate.kind] ?? 3) - || left.candidate.path.localeCompare(right.candidate.path); + const priority = { 'commitlog-offset': 0, commitlog: 1, snapshot: 2 }; + return ( + (priority[left.candidate.kind] ?? 3) - + (priority[right.candidate.kind] ?? 3) || + left.candidate.path.localeCompare(right.candidate.path) + ); }); - for (const {candidate, absolutePath} of existing) { - rmSync(absolutePath, {recursive: candidate.kind === 'snapshot', force: false}); + for (const { candidate, absolutePath } of existing) { + rmSync(absolutePath, { + recursive: candidate.kind === 'snapshot', + force: false, + }); console.log(`[database-backup] 已清理 history 源文件: ${candidate.path}`); } - return {deletedCount: existing.length, alreadyMissingCount: candidates.length - existing.length}; + return { + deletedCount: existing.length, + alreadyMissingCount: candidates.length - existing.length, + }; } -function writeManifest({manifestPath, payload}) { +function writeManifest({ manifestPath, payload }) { writeFileSync(manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); } @@ -1086,15 +1371,21 @@ async function sha256FileHex(filePath) { return hash.digest('hex'); } -function directFilesStatePath({workDir, database}) { - return join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-files-state.json.gz`); +function directFilesStatePath({ workDir, database }) { + return join( + workDir, + `${sanitizeObjectPart(database, 'spacetimedb')}-files-state.json.gz`, + ); } -function legacyDirectFilesStatePath({workDir, database}) { - return join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-files-state.json`); +function legacyDirectFilesStatePath({ workDir, database }) { + return join( + workDir, + `${sanitizeObjectPart(database, 'spacetimedb')}-files-state.json`, + ); } -function directCatalogLocalPaths({workDir, database, catalog}) { +function directCatalogLocalPaths({ workDir, database, catalog }) { const baseName = `${sanitizeObjectPart(database, 'spacetimedb')}-${catalog.mode}-${catalog.catalogId}.catalog.json`; return { jsonPath: join(workDir, baseName), @@ -1102,8 +1393,12 @@ function directCatalogLocalPaths({workDir, database, catalog}) { }; } -function readLocalDirectCatalog({workDir, database, catalog}) { - const {jsonPath, gzipPath} = directCatalogLocalPaths({workDir, database, catalog}); +function readLocalDirectCatalog({ workDir, database, catalog }) { + const { jsonPath, gzipPath } = directCatalogLocalPaths({ + workDir, + database, + catalog, + }); let body = null; if (existsSync(jsonPath)) { body = readFileSync(jsonPath); @@ -1113,16 +1408,21 @@ function readLocalDirectCatalog({workDir, database, catalog}) { if (!body) { return null; } - if (body.length !== catalog.contentLength || sha256Hex(body) !== catalog.sha256) { - throw new Error(`本地 files catalog 长度或 SHA 与 state 引用不匹配: ${jsonPath}`); + if ( + body.length !== catalog.contentLength || + sha256Hex(body) !== catalog.sha256 + ) { + throw new Error( + `本地 files catalog 长度或 SHA 与 state 引用不匹配: ${jsonPath}`, + ); } const payload = JSON.parse(body.toString('utf8')); if ( - payload.schemaVersion !== DIRECT_FILES_CATALOG_SCHEMA_VERSION - || payload.database !== database - || payload.mode !== catalog.mode - || payload.catalogId !== catalog.catalogId - || !Array.isArray(payload.files) + payload.schemaVersion !== DIRECT_FILES_CATALOG_SCHEMA_VERSION || + payload.database !== database || + payload.mode !== catalog.mode || + payload.catalogId !== catalog.catalogId || + !Array.isArray(payload.files) ) { throw new Error(`本地 files catalog 与 state 引用不匹配: ${jsonPath}`); } @@ -1131,32 +1431,45 @@ function readLocalDirectCatalog({workDir, database, catalog}) { function readJsonOrGzip(filePath) { const body = readFileSync(filePath); - const decoded = filePath.endsWith('.gz') || (body[0] === 0x1f && body[1] === 0x8b) - ? gunzipSync(body) - : body; + const decoded = + filePath.endsWith('.gz') || (body[0] === 0x1f && body[1] === 0x8b) + ? gunzipSync(body) + : body; return JSON.parse(decoded.toString('utf8')); } -function compactDirectCatalogFile({workDir, database, catalog}) { - const {jsonPath, gzipPath} = directCatalogLocalPaths({workDir, database, catalog}); +function compactDirectCatalogFile({ workDir, database, catalog }) { + const { jsonPath, gzipPath } = directCatalogLocalPaths({ + workDir, + database, + catalog, + }); if (!existsSync(jsonPath)) { - return existsSync(gzipPath) ? {compressed: false, gzipPath} : null; + return existsSync(gzipPath) ? { compressed: false, gzipPath } : null; } const body = readFileSync(jsonPath); - atomicWriteBuffer(gzipPath, gzipSync(body, {level: 9})); - rmSync(jsonPath, {force: false}); - return {compressed: true, gzipPath}; + atomicWriteBuffer(gzipPath, gzipSync(body, { level: 9 })); + rmSync(jsonPath, { force: false }); + return { compressed: true, gzipPath }; } -function compactDirectFilesLocalMetadata({workDir, database, nextState, transientCatalogPaths = []}) { +function compactDirectFilesLocalMetadata({ + workDir, + database, + nextState, + transientCatalogPaths = [], +}) { const keepCatalog = nextState.latestCatalog; const keepCatalogIds = new Set([keepCatalog?.catalogId].filter(Boolean)); const databasePart = sanitizeObjectPart(database, 'spacetimedb'); - const catalogPattern = new RegExp(`^${databasePart.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}-(full|history)-([a-f0-9]{64})\\.catalog\\.json(?:\\.gz)?$`, 'u'); + const catalogPattern = new RegExp( + `^${databasePart.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}-(full|history)-([a-f0-9]{64})\\.catalog\\.json(?:\\.gz)?$`, + 'u', + ); let compressedCatalogCount = 0; let deletedCatalogCount = 0; let compactedResultCount = 0; - for (const entry of readdirSync(workDir, {withFileTypes: true})) { + for (const entry of readdirSync(workDir, { withFileTypes: true })) { if (!entry.isFile()) { continue; } @@ -1164,25 +1477,32 @@ function compactDirectFilesLocalMetadata({workDir, database, nextState, transien if (!match) { continue; } - const catalog = {mode: match[1], catalogId: match[2]}; + const catalog = { mode: match[1], catalogId: match[2] }; if (keepCatalogIds.has(catalog.catalogId) && catalog.mode === 'full') { - readLocalDirectCatalog({workDir, database, catalog: keepCatalog}); - if (!entry.name.endsWith('.gz') && compactDirectCatalogFile({workDir, database, catalog})?.compressed) { + readLocalDirectCatalog({ workDir, database, catalog: keepCatalog }); + if ( + !entry.name.endsWith('.gz') && + compactDirectCatalogFile({ workDir, database, catalog })?.compressed + ) { compressedCatalogCount += 1; } continue; } - rmSync(join(workDir, entry.name), {force: false}); + rmSync(join(workDir, entry.name), { force: false }); deletedCatalogCount += 1; } for (const filePath of transientCatalogPaths) { if (existsSync(filePath)) { - rmSync(filePath, {force: false}); + rmSync(filePath, { force: false }); deletedCatalogCount += 1; } } - for (const entry of readdirSync(workDir, {withFileTypes: true})) { - if (!entry.isFile() || !entry.name.endsWith('.json') || entry.name.endsWith('.catalog.json')) { + for (const entry of readdirSync(workDir, { withFileTypes: true })) { + if ( + !entry.isFile() || + !entry.name.endsWith('.json') || + entry.name.endsWith('.catalog.json') + ) { continue; } const filePath = join(workDir, entry.name); @@ -1193,18 +1513,24 @@ function compactDirectFilesLocalMetadata({workDir, database, nextState, transien continue; } if ( - payload?.catalog?.schemaVersion !== DIRECT_FILES_CATALOG_SCHEMA_VERSION - || payload.catalog.database !== database - || payload.catalog.bucket !== nextState.bucket - || !Array.isArray(payload.catalog.files) + payload?.catalog?.schemaVersion !== DIRECT_FILES_CATALOG_SCHEMA_VERSION || + payload.catalog.database !== database || + payload.catalog.bucket !== nextState.bucket || + !Array.isArray(payload.catalog.files) ) { continue; } atomicWriteJson(filePath, compactDirectFilesResult(payload)); compactedResultCount += 1; } - const result = {compressedCatalogCount, deletedCatalogCount, compactedResultCount}; - console.log(`[database-backup] files 本地元数据清理: ${JSON.stringify(result)}`); + const result = { + compressedCatalogCount, + deletedCatalogCount, + compactedResultCount, + }; + console.log( + `[database-backup] files 本地元数据清理: ${JSON.stringify(result)}`, + ); return result; } @@ -1216,11 +1542,13 @@ function normalizeObjectPrefix(objectPrefix, database) { .filter(Boolean) .map((part) => sanitizeObjectPart(part, 'backup')) .join('/'); - return [prefix, sanitizeObjectPart(database, 'spacetimedb')].filter(Boolean).join('/'); + return [prefix, sanitizeObjectPart(database, 'spacetimedb')] + .filter(Boolean) + .join('/'); } function directFileIdentity(filePath) { - const stat = lstatSync(filePath, {bigint: true}); + const stat = lstatSync(filePath, { bigint: true }); if (!stat.isFile() || stat.isSymbolicLink()) { throw new Error(`files 模式只允许普通文件: ${filePath}`); } @@ -1234,43 +1562,63 @@ function directFileIdentity(filePath) { } function sameDirectFileIdentity(left, right) { - return left.dev === right.dev - && left.ino === right.ino - && left.size === right.size - && left.mtimeNs === right.mtimeNs - && left.mode === right.mode; + return ( + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mtimeNs === right.mtimeNs && + left.mode === right.mode + ); } -export async function collectDirectFileEntries({dataDir, candidates = null, objectPrefix, database}) { +export async function collectDirectFileEntries({ + dataDir, + candidates = null, + objectPrefix, + database, +}) { const resolvedDataDir = resolvePath(dataDir); - if (!existsSync(resolvedDataDir) || !lstatSync(resolvedDataDir).isDirectory()) { + if ( + !existsSync(resolvedDataDir) || + !lstatSync(resolvedDataDir).isDirectory() + ) { throw new Error(`files 数据目录不存在或不是目录: ${resolvedDataDir}`); } const files = new Map(); const symlinks = new Map(); const directories = new Set(['.']); - const roots = candidates === null - ? [{absolutePath: resolvedDataDir, relativePath: '.'}] - : candidates.map((candidate) => ({ - absolutePath: resolve(resolvedDataDir, candidate.path), - relativePath: assertSafeRelativePath(resolvedDataDir, resolve(resolvedDataDir, candidate.path)), - })); + const roots = + candidates === null + ? [{ absolutePath: resolvedDataDir, relativePath: '.' }] + : candidates.map((candidate) => ({ + absolutePath: resolve(resolvedDataDir, candidate.path), + relativePath: assertSafeRelativePath( + resolvedDataDir, + resolve(resolvedDataDir, candidate.path), + ), + })); const visit = async (absolutePath, relativePath) => { const stat = lstatSync(absolutePath); if (stat.isSymbolicLink()) { const target = readlinkSync(absolutePath, 'utf8'); if (!target || isAbsolute(target)) { - throw new Error(`files 模式只允许 data-dir 内部的相对符号链接: ${absolutePath} -> ${target}`); + throw new Error( + `files 模式只允许 data-dir 内部的相对符号链接: ${absolutePath} -> ${target}`, + ); } - assertSafeRelativePath(resolvedDataDir, resolve(dirname(absolutePath), target)); - symlinks.set(relativePath, {path: relativePath, target}); + assertSafeRelativePath( + resolvedDataDir, + resolve(dirname(absolutePath), target), + ); + symlinks.set(relativePath, { path: relativePath, target }); return; } if (stat.isDirectory()) { directories.add(relativePath); for (const name of readdirSync(absolutePath).sort()) { - const childRelative = relativePath === '.' ? name : `${relativePath}/${name}`; + const childRelative = + relativePath === '.' ? name : `${relativePath}/${name}`; await visit(join(absolutePath, name), childRelative); } return; @@ -1285,17 +1633,25 @@ export async function collectDirectFileEntries({dataDir, candidates = null, obje throw new Error(`files 扫描期间源文件发生变化: ${relativePath}`); } const basePrefix = normalizeObjectPrefix(objectPrefix, database); - files.set(relativePath, { + const file = { path: relativePath, sizeBytes: Number(after.size), sha256, mode: after.mode, objectKey: `${basePrefix}/files/sha256/${sha256.slice(0, 2)}/${sha256}`, - sourceStat: after, + }; + // 上传前后的 inode/stat 仍用于防止在线扫描漂移,但设为不可枚举,避免 + // 把仅供本地校验的副本再次写入 catalog 或 result JSON。 + Object.defineProperty(file, 'sourceStat', { + value: after, + enumerable: false, }); + files.set(relativePath, file); }; - for (const root of roots.sort((left, right) => left.relativePath.localeCompare(right.relativePath))) { + for (const root of roots.sort((left, right) => + left.relativePath.localeCompare(right.relativePath), + )) { if (!existsSync(root.absolutePath)) { throw new Error(`files 候选在扫描前消失: ${root.relativePath}`); } @@ -1303,23 +1659,64 @@ export async function collectDirectFileEntries({dataDir, candidates = null, obje } return { directories: [...directories].sort(), - files: [...files.values()].sort((left, right) => left.path.localeCompare(right.path)), - symlinks: [...symlinks.values()].sort((left, right) => left.path.localeCompare(right.path)), + files: [...files.values()].sort((left, right) => + left.path.localeCompare(right.path), + ), + symlinks: [...symlinks.values()].sort((left, right) => + left.path.localeCompare(right.path), + ), }; } -function directCatalogIdentity({mode, baselineCatalogId, rootName, directories, files, symlinks}) { - return sha256Hex(JSON.stringify({ - mode, - baselineCatalogId: baselineCatalogId || '', - rootName, - directories, - files: files.map(({path, sizeBytes, sha256, mode, objectKey}) => ({path, sizeBytes, sha256, mode, objectKey})), - symlinks, - })); +function directCatalogIdentity({ + mode, + baselineCatalogId, + rootName, + directories, + files, + symlinks, +}) { + // 不把数十万条文件元数据先拼成一个巨型 JSON 字符串;分段写入 hash + // 保持与 JSON.stringify 同样的字段顺序和转义结果,同时把峰值降到单条记录。 + const hash = createHash('sha256'); + hash.update('{"mode":'); + hash.update(JSON.stringify(mode)); + hash.update(',"baselineCatalogId":'); + hash.update(JSON.stringify(baselineCatalogId || '')); + hash.update(',"rootName":'); + hash.update(JSON.stringify(rootName)); + hash.update(',"directories":'); + updateJsonArrayHash(hash, directories, (directory) => + JSON.stringify(directory), + ); + hash.update(',"files":'); + updateJsonArrayHash(hash, files, (file) => + JSON.stringify({ + path: file.path, + sizeBytes: file.sizeBytes, + sha256: file.sha256, + mode: file.mode, + objectKey: file.objectKey, + }), + ); + hash.update(',"symlinks":'); + updateJsonArrayHash(hash, symlinks, (symlink) => JSON.stringify(symlink)); + hash.update('}'); + return hash.digest('hex'); } -function readDirectFilesState(statePath, {database, bucket}) { +function updateJsonArrayHash(hash, values, serialize) { + hash.update('['); + values.forEach((value, index) => { + if (index > 0) { + hash.update(','); + } + hash.update(serialize(value)); + }); + hash.update(']'); +} + +function readDirectFilesState(statePath, { database, bucket }) { const candidates = statePath.endsWith('.gz') ? [statePath, statePath.slice(0, -3)] : [statePath, `${statePath}.gz`]; @@ -1329,24 +1726,30 @@ function readDirectFilesState(statePath, {database, bucket}) { } const state = readJsonOrGzip(existingPath); if ( - ![LEGACY_DIRECT_FILES_STATE_SCHEMA_VERSION, DIRECT_FILES_STATE_SCHEMA_VERSION].includes(state.schemaVersion) - || state.backupKind !== 'spacetimedb-direct-files-state' - || state.database !== database - || state.bucket !== bucket + ![ + LEGACY_DIRECT_FILES_STATE_SCHEMA_VERSION, + DIRECT_FILES_STATE_SCHEMA_VERSION, + ].includes(state.schemaVersion) || + state.backupKind !== 'spacetimedb-direct-files-state' || + state.database !== database || + state.bucket !== bucket ) { throw new Error(`files state 与本次数据源或 bucket 不匹配: ${statePath}`); } return state; } -function directPreviousFiles({state, workDir, database}) { +function directPreviousFiles({ state, workDir, database }) { if (!state?.latestCatalog) { return []; } if (Array.isArray(state?.latestCatalog?.files)) { return state.latestCatalog.files; } - return readLocalDirectCatalog({workDir, database, catalog: state?.latestCatalog})?.files ?? []; + return ( + readLocalDirectCatalog({ workDir, database, catalog: state?.latestCatalog }) + ?.files ?? [] + ); } async function ensureDirectObject({ @@ -1360,9 +1763,11 @@ async function ensureDirectObject({ }) { const absolutePath = resolve(dataDir, file.path); assertSafeRelativePath(dataDir, absolutePath); - if (previousFile?.sha256 === file.sha256 - && previousFile?.sizeBytes === file.sizeBytes - && previousFile?.objectKey === file.objectKey) { + if ( + previousFile?.sha256 === file.sha256 && + previousFile?.sizeBytes === file.sizeBytes && + previousFile?.objectKey === file.objectKey + ) { if (verifyCatalogReuse) { await verifyFn({ ...uploadOptions, @@ -1383,7 +1788,7 @@ async function ensureDirectObject({ contentLength: file.sizeBytes, archiveSha256: file.sha256, }); - return {status: 'oss-reused', objectKey: file.objectKey}; + return { status: 'oss-reused', objectKey: file.objectKey }; } catch (error) { if (error?.status !== 404) { throw error; @@ -1406,10 +1811,16 @@ async function ensureDirectObject({ if (!sameDirectFileIdentity(afterUpload, file.sourceStat)) { throw new Error(`files 上传期间源文件 stat 漂移: ${file.path}`); } - return {status: 'uploaded', objectKey: file.objectKey}; + return { status: 'uploaded', objectKey: file.objectKey }; } -async function ensureDirectManifest({manifestPath, objectKey, uploadOptions, uploadManifestFn, verifyFn}) { +async function ensureDirectManifest({ + manifestPath, + objectKey, + uploadOptions, + uploadManifestFn, + verifyFn, +}) { const body = readFileSync(manifestPath); const archiveSha256 = sha256Hex(body); try { @@ -1419,13 +1830,19 @@ async function ensureDirectManifest({manifestPath, objectKey, uploadOptions, upl contentLength: body.length, archiveSha256, }); - return {objectKey, contentLength: body.length, archiveSha256, verifiedAt: verification.verifiedAt, reused: true}; + return { + objectKey, + contentLength: body.length, + archiveSha256, + verifiedAt: verification.verifiedAt, + reused: true, + }; } catch (error) { if (error?.status !== 404) { throw error; } } - return uploadManifestFn({manifestPath, ...uploadOptions, objectKey}); + return uploadManifestFn({ manifestPath, ...uploadOptions, objectKey }); } function directCatalogRef(catalog) { @@ -1439,7 +1856,7 @@ function directCatalogRef(catalog) { }; } -function normalizeDirectFilesState({state, dataDir, database, bucket}) { +function normalizeDirectFilesState({ state, dataDir, database, bucket }) { return { schemaVersion: DIRECT_FILES_STATE_SCHEMA_VERSION, backupKind: 'spacetimedb-direct-files-state', @@ -1447,18 +1864,26 @@ function normalizeDirectFilesState({state, dataDir, database, bucket}) { dataDir, bucket, updatedAt: new Date().toISOString(), - baselineCatalog: assertDirectCatalogRef(state?.baselineCatalog, 'full', 'baseline full'), - latestCatalog: assertDirectCatalogRef(state?.latestCatalog, 'full', 'latest full'), - historyCatalogs: (state?.historyCatalogs ?? []).map((catalog) => ( - assertDirectCatalogRef(catalog, 'history', 'history') - )), + baselineCatalog: assertDirectCatalogRef( + state?.baselineCatalog, + 'full', + 'baseline full', + ), + latestCatalog: assertDirectCatalogRef( + state?.latestCatalog, + 'full', + 'latest full', + ), + historyCatalogs: (state?.historyCatalogs ?? []).map((catalog) => + assertDirectCatalogRef(catalog, 'history', 'history'), + ), }; } -function persistDirectFilesState({statePath, legacyStatePath, state}) { +function persistDirectFilesState({ statePath, legacyStatePath, state }) { atomicWriteGzipJson(statePath, state); if (legacyStatePath !== statePath && existsSync(legacyStatePath)) { - rmSync(legacyStatePath, {force: false}); + rmSync(legacyStatePath, { force: false }); } } @@ -1466,7 +1891,7 @@ function compactDirectFilesResult(result) { if (!result.catalog) { return result; } - const {catalog, ...rest} = result; + const { catalog, ...rest } = result; return { ...rest, catalog: { @@ -1480,34 +1905,40 @@ function compactDirectFilesResult(result) { baselineCatalogId: catalog.baselineCatalogId, rootName: catalog.rootName, fileCount: Array.isArray(catalog.files) ? catalog.files.length : 0, - symlinkCount: Array.isArray(catalog.symlinks) ? catalog.symlinks.length : 0, + symlinkCount: Array.isArray(catalog.symlinks) + ? catalog.symlinks.length + : 0, }, }; } function assertDirectCatalogRef(catalog, expectedMode, label) { if ( - !catalog - || catalog.mode !== expectedMode - || !/^[a-f0-9]{64}$/u.test(catalog.catalogId) - || typeof catalog.objectKey !== 'string' - || !catalog.objectKey - || !Number.isSafeInteger(catalog.contentLength) - || catalog.contentLength <= 0 - || !/^[a-f0-9]{64}$/u.test(catalog.sha256) - || typeof catalog.verifiedAt !== 'string' - || !catalog.verifiedAt + !catalog || + catalog.mode !== expectedMode || + !/^[a-f0-9]{64}$/u.test(catalog.catalogId) || + typeof catalog.objectKey !== 'string' || + !catalog.objectKey || + !Number.isSafeInteger(catalog.contentLength) || + catalog.contentLength <= 0 || + !/^[a-f0-9]{64}$/u.test(catalog.sha256) || + typeof catalog.verifiedAt !== 'string' || + !catalog.verifiedAt ) { throw new Error(`files ${label} catalog ref 无效。`); } return directCatalogRef(catalog); } -function buildDirectFilesLatest({database, bucket, state}) { - const latestFullCatalog = assertDirectCatalogRef(state?.latestCatalog, 'full', 'latest full'); - const historyCatalogs = (state?.historyCatalogs ?? []).map((catalog) => ( - assertDirectCatalogRef(catalog, 'history', 'history') - )); +function buildDirectFilesLatest({ database, bucket, state }) { + const latestFullCatalog = assertDirectCatalogRef( + state?.latestCatalog, + 'full', + 'latest full', + ); + const historyCatalogs = (state?.historyCatalogs ?? []).map((catalog) => + assertDirectCatalogRef(catalog, 'history', 'history'), + ); return { schemaVersion: DIRECT_FILES_LATEST_SCHEMA_VERSION, backupKind: 'spacetimedb-direct-files-latest', @@ -1519,20 +1950,26 @@ function buildDirectFilesLatest({database, bucket, state}) { }; } -function validateDirectFilesLatest(latest, {database, bucket}) { +function validateDirectFilesLatest(latest, { database, bucket }) { if ( - latest?.schemaVersion !== DIRECT_FILES_LATEST_SCHEMA_VERSION - || latest.backupKind !== 'spacetimedb-direct-files-latest' - || latest.database !== database - || latest.bucket !== bucket - || !Array.isArray(latest.historyCatalogs) + latest?.schemaVersion !== DIRECT_FILES_LATEST_SCHEMA_VERSION || + latest.backupKind !== 'spacetimedb-direct-files-latest' || + latest.database !== database || + latest.bucket !== bucket || + !Array.isArray(latest.historyCatalogs) ) { throw new Error('files latest pointer 契约无效。'); } return { ...latest, - latestFullCatalog: assertDirectCatalogRef(latest.latestFullCatalog, 'full', 'latest full'), - historyCatalogs: latest.historyCatalogs.map((catalog) => assertDirectCatalogRef(catalog, 'history', 'history')), + latestFullCatalog: assertDirectCatalogRef( + latest.latestFullCatalog, + 'full', + 'latest full', + ), + historyCatalogs: latest.historyCatalogs.map((catalog) => + assertDirectCatalogRef(catalog, 'history', 'history'), + ), }; } @@ -1546,8 +1983,11 @@ async function publishDirectFilesLatest({ uploadManifestFn, verifyFn, }) { - const latest = buildDirectFilesLatest({database, bucket, state}); - for (const catalogRef of [latest.latestFullCatalog, ...latest.historyCatalogs]) { + const latest = buildDirectFilesLatest({ database, bucket, state }); + for (const catalogRef of [ + latest.latestFullCatalog, + ...latest.historyCatalogs, + ]) { await verifyFn({ ...uploadOptions, objectKey: catalogRef.objectKey, @@ -1555,10 +1995,17 @@ async function publishDirectFilesLatest({ archiveSha256: catalogRef.sha256, }); } - const latestPath = join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-latest.json`); + const latestPath = join( + workDir, + `${sanitizeObjectPart(database, 'spacetimedb')}-latest.json`, + ); const latestObjectKey = `${normalizeObjectPrefix(objectPrefix, database)}/latest.json`; - writeManifest({manifestPath: latestPath, payload: latest}); - const uploaded = await uploadManifestFn({manifestPath: latestPath, ...uploadOptions, objectKey: latestObjectKey}); + writeManifest({ manifestPath: latestPath, payload: latest }); + const uploaded = await uploadManifestFn({ + manifestPath: latestPath, + ...uploadOptions, + objectKey: latestObjectKey, + }); const verification = await verifyFn({ ...uploadOptions, objectKey: latestObjectKey, @@ -1590,29 +2037,46 @@ export async function runDirectFilesBackup({ verifyFn = verifyOssObject, concurrency = 1, }) { - mkdirSync(workDir, {recursive: true}); - const statePath = directFilesStatePath({workDir, database}); - const legacyStatePath = legacyDirectFilesStatePath({workDir, database}); - const state = readDirectFilesState(statePath, {database, bucket}); - if (mode === 'history' && (!state?.baselineCatalog || state?.latestCatalog?.mode !== 'full')) { - throw new Error(`files history 模式缺少已发布 full baseline catalog: ${statePath}`); + mkdirSync(workDir, { recursive: true }); + const statePath = directFilesStatePath({ workDir, database }); + const legacyStatePath = legacyDirectFilesStatePath({ workDir, database }); + const state = readDirectFilesState(statePath, { database, bucket }); + if ( + mode === 'history' && + (!state?.baselineCatalog || state?.latestCatalog?.mode !== 'full') + ) { + throw new Error( + `files history 模式缺少已发布 full baseline catalog: ${statePath}`, + ); } - const plan = mode === 'history' ? discoverHistoryPlan({dataDir}) : null; + const plan = mode === 'history' ? discoverHistoryPlan({ dataDir }) : null; const collected = await collectDirectFileEntries({ dataDir, candidates: plan?.candidates ?? null, objectPrefix, database, }); - const baselineCatalogId = mode === 'history' ? (state?.baselineCatalog?.catalogId ?? '') : ''; + const baselineCatalogId = + mode === 'history' ? (state?.baselineCatalog?.catalogId ?? '') : ''; const rootName = basename(dataDir); - const catalogId = directCatalogIdentity({mode, baselineCatalogId, rootName, ...collected}); + const catalogId = directCatalogIdentity({ + mode, + baselineCatalogId, + rootName, + ...collected, + }); const basePrefix = normalizeObjectPrefix(objectPrefix, database); const catalogObjectKey = `${basePrefix}/catalogs/${mode}/${catalogId}.json`; - const catalogPath = join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-${mode}-${catalogId}.catalog.json`); + const catalogPath = join( + workDir, + `${sanitizeObjectPart(database, 'spacetimedb')}-${mode}-${catalogId}.catalog.json`, + ); const catalog = { schemaVersion: DIRECT_FILES_CATALOG_SCHEMA_VERSION, - backupKind: mode === 'full' ? 'spacetimedb-data-dir-files' : 'spacetimedb-history-files', + backupKind: + mode === 'full' + ? 'spacetimedb-data-dir-files' + : 'spacetimedb-history-files', database, bucket, mode, @@ -1621,10 +2085,10 @@ export async function runDirectFilesBackup({ baselineCatalogId, rootName, directories: collected.directories, - files: collected.files.map(({sourceStat: _sourceStat, ...file}) => file), + files: collected.files, symlinks: collected.symlinks, }; - writeManifest({manifestPath: catalogPath, payload: catalog}); + writeManifest({ manifestPath: catalogPath, payload: catalog }); const summary = { statePath, catalogPath, @@ -1632,16 +2096,22 @@ export async function runDirectFilesBackup({ catalogId, fileCount: collected.files.length, symlinkCount: collected.symlinks.length, - totalSizeBytes: collected.files.reduce((sum, file) => sum + BigInt(file.sizeBytes), 0n).toString(), + totalSizeBytes: collected.files + .reduce((sum, file) => sum + BigInt(file.sizeBytes), 0n) + .toString(), candidateCount: plan?.candidates.length ?? 0, }; if (resultFile) { - atomicWriteJson(resolvePath(resultFile), {...summary, dryRun}); + atomicWriteJson(resolvePath(resultFile), { ...summary, dryRun }); } - console.log(`[database-backup] files ${mode}: files=${summary.fileCount}, symlinks=${summary.symlinkCount}, size=${formatBytes(summary.totalSizeBytes)}, catalog=${catalogId}`); + console.log( + `[database-backup] files ${mode}: files=${summary.fileCount}, symlinks=${summary.symlinkCount}, size=${formatBytes(summary.totalSizeBytes)}, catalog=${catalogId}`, + ); if (dryRun) { - console.log('[database-backup] files dry-run,仅扫描并生成本地 catalog,不上传或删除。'); - return {...summary, catalog, uploadedCount: 0, reusedCount: 0}; + console.log( + '[database-backup] files dry-run,仅扫描并生成本地 catalog,不上传或删除。', + ); + return { ...summary, catalog, uploadedCount: 0, reusedCount: 0 }; } if (mode === 'history' && plan.candidates.length === 0) { await verifyFn({ @@ -1660,8 +2130,17 @@ export async function runDirectFilesBackup({ uploadManifestFn, verifyFn, }); - const compactedState = normalizeDirectFilesState({state, dataDir, database, bucket}); - persistDirectFilesState({statePath, legacyStatePath, state: compactedState}); + const compactedState = normalizeDirectFilesState({ + state, + dataDir, + database, + bucket, + }); + persistDirectFilesState({ + statePath, + legacyStatePath, + state: compactedState, + }); const metadataCleanup = compactDirectFilesLocalMetadata({ workDir, database, @@ -1679,13 +2158,24 @@ export async function runDirectFilesBackup({ metadataCleanup, }; if (resultFile) { - atomicWriteJson(resolvePath(resultFile), compactDirectFilesResult(emptyResult)); + atomicWriteJson( + resolvePath(resultFile), + compactDirectFilesResult(emptyResult), + ); } return emptyResult; } - if (state?.latestCatalog?.catalogId === catalogId && state.latestCatalog.mode === mode) { - await verifyFn({...uploadOptions, objectKey: state.latestCatalog.objectKey, contentLength: state.latestCatalog.contentLength, archiveSha256: state.latestCatalog.sha256}); + if ( + state?.latestCatalog?.catalogId === catalogId && + state.latestCatalog.mode === mode + ) { + await verifyFn({ + ...uploadOptions, + objectKey: state.latestCatalog.objectKey, + contentLength: state.latestCatalog.contentLength, + archiveSha256: state.latestCatalog.sha256, + }); if (mode === 'full') { const latestPointer = await publishDirectFilesLatest({ workDir, @@ -1697,8 +2187,17 @@ export async function runDirectFilesBackup({ uploadManifestFn, verifyFn, }); - const compactedState = normalizeDirectFilesState({state, dataDir, database, bucket}); - persistDirectFilesState({statePath, legacyStatePath, state: compactedState}); + const compactedState = normalizeDirectFilesState({ + state, + dataDir, + database, + bucket, + }); + persistDirectFilesState({ + statePath, + legacyStatePath, + state: compactedState, + }); const metadataCleanup = compactDirectFilesLocalMetadata({ workDir, database, @@ -1726,13 +2225,18 @@ export async function runDirectFilesBackup({ }); } - const previousFiles = new Map(directPreviousFiles({state, workDir, database}).map((file) => [file.path, file])); + const previousFiles = new Map( + directPreviousFiles({ state, workDir, database }).map((file) => [ + file.path, + file, + ]), + ); let uploadedCount = 0; let reusedCount = 0; let nextIndex = 0; let completedCount = 0; const workerCount = Math.min(concurrency, collected.files.length); - const workers = Array.from({length: workerCount}, async () => { + const workers = Array.from({ length: workerCount }, async () => { while (nextIndex < collected.files.length) { const index = nextIndex; nextIndex += 1; @@ -1752,8 +2256,14 @@ export async function runDirectFilesBackup({ reusedCount += 1; } completedCount += 1; - if (collected.files.length <= 100 || completedCount % 1000 === 0 || completedCount === collected.files.length) { - console.log(`[database-backup] files 进度: ${completedCount}/${collected.files.length} (${result.status}) ${file.path}`); + if ( + collected.files.length <= 100 || + completedCount % 1000 === 0 || + completedCount === collected.files.length + ) { + console.log( + `[database-backup] files 进度: ${completedCount}/${collected.files.length} (${result.status}) ${file.path}`, + ); } } }); @@ -1765,7 +2275,12 @@ export async function runDirectFilesBackup({ uploadManifestFn, verifyFn, }); - await verifyFn({...uploadOptions, objectKey: catalogObjectKey, contentLength: catalogUpload.contentLength, archiveSha256: catalogUpload.archiveSha256}); + await verifyFn({ + ...uploadOptions, + objectKey: catalogObjectKey, + contentLength: catalogUpload.contentLength, + archiveSha256: catalogUpload.archiveSha256, + }); if (mode === 'history') { await verifyFn({ @@ -1791,15 +2306,18 @@ export async function runDirectFilesBackup({ bucket, updatedAt: new Date().toISOString(), baselineCatalog: directCatalogRef(state?.baselineCatalog ?? catalogRef), - latestCatalog: directCatalogRef(mode === 'full' ? catalogRef : state.latestCatalog), - historyCatalogs: mode === 'history' - ? [ - ...(state.historyCatalogs ?? []) - .filter((item) => item.catalogId !== catalogId) - .map((item) => directCatalogRef(item)), - directCatalogRef(catalogRef), - ] - : (state?.historyCatalogs ?? []).map((item) => directCatalogRef(item)), + latestCatalog: directCatalogRef( + mode === 'full' ? catalogRef : state.latestCatalog, + ), + historyCatalogs: + mode === 'history' + ? [ + ...(state.historyCatalogs ?? []) + .filter((item) => item.catalogId !== catalogId) + .map((item) => directCatalogRef(item)), + directCatalogRef(catalogRef), + ] + : (state?.historyCatalogs ?? []).map((item) => directCatalogRef(item)), }; const latestPointer = await publishDirectFilesLatest({ workDir, @@ -1811,7 +2329,7 @@ export async function runDirectFilesBackup({ uploadManifestFn, verifyFn, }); - persistDirectFilesState({statePath, legacyStatePath, state: nextState}); + persistDirectFilesState({ statePath, legacyStatePath, state: nextState }); const metadataCleanup = compactDirectFilesLocalMetadata({ workDir, database, @@ -1819,7 +2337,10 @@ export async function runDirectFilesBackup({ }); let cleanup = null; if (mode === 'history') { - cleanup = cleanupHistoryCandidates({dataDir, candidates: plan.candidates}); + cleanup = cleanupHistoryCandidates({ + dataDir, + candidates: plan.candidates, + }); } const finalResult = { ...summary, @@ -1831,12 +2352,15 @@ export async function runDirectFilesBackup({ metadataCleanup, }; if (resultFile) { - atomicWriteJson(resolvePath(resultFile), compactDirectFilesResult(finalResult)); + atomicWriteJson( + resolvePath(resultFile), + compactDirectFilesResult(finalResult), + ); } return finalResult; } -async function downloadOssBuffer({objectKey, uploadOptions}) { +async function downloadOssBuffer({ objectKey, uploadOptions }) { const response = await signedOssRequest({ ...ossRequestDefaults(uploadOptions), method: 'GET', @@ -1846,7 +2370,7 @@ async function downloadOssBuffer({objectKey, uploadOptions}) { return Buffer.from(await response.arrayBuffer()); } -async function downloadOssFile({objectKey, destinationPath, uploadOptions}) { +async function downloadOssFile({ objectKey, destinationPath, uploadOptions }) { const response = await signedOssRequest({ ...ossRequestDefaults(uploadOptions), method: 'GET', @@ -1854,50 +2378,67 @@ async function downloadOssFile({objectKey, destinationPath, uploadOptions}) { operation: '下载对象', }); const tempPath = `${destinationPath}.partial-${process.pid}`; - rmSync(tempPath, {force: true}); + rmSync(tempPath, { force: true }); try { if (response.body) { - await pipeline(Readable.fromWeb(response.body), createWriteStream(tempPath, {mode: 0o600})); + await pipeline( + Readable.fromWeb(response.body), + createWriteStream(tempPath, { mode: 0o600 }), + ); } else { - writeFileSync(tempPath, Buffer.alloc(0), {mode: 0o600}); + writeFileSync(tempPath, Buffer.alloc(0), { mode: 0o600 }); } renameSync(tempPath, destinationPath); } catch (error) { - rmSync(tempPath, {force: true}); + rmSync(tempPath, { force: true }); throw error; } } -async function loadDirectFilesCatalog({catalogRef, database, bucket, uploadOptions, downloadBufferFn}) { - const catalogBody = await downloadBufferFn({objectKey: catalogRef.objectKey, uploadOptions}); - if (catalogBody.length !== catalogRef.contentLength || sha256Hex(catalogBody) !== catalogRef.sha256) { - throw new Error(`files restore catalog 长度或 SHA-256 不一致: ${catalogRef.objectKey}`); +async function loadDirectFilesCatalog({ + catalogRef, + database, + bucket, + uploadOptions, + downloadBufferFn, +}) { + const catalogBody = await downloadBufferFn({ + objectKey: catalogRef.objectKey, + uploadOptions, + }); + if ( + catalogBody.length !== catalogRef.contentLength || + sha256Hex(catalogBody) !== catalogRef.sha256 + ) { + throw new Error( + `files restore catalog 长度或 SHA-256 不一致: ${catalogRef.objectKey}`, + ); } const catalog = JSON.parse(catalogBody.toString('utf8')); if ( - catalog.schemaVersion !== DIRECT_FILES_CATALOG_SCHEMA_VERSION - || catalog.backupKind !== 'spacetimedb-data-dir-files' - || catalog.database !== database - || catalog.bucket !== bucket - || catalog.catalogId !== catalogRef.catalogId - || !Array.isArray(catalog.directories) - || !Array.isArray(catalog.files) + catalog.schemaVersion !== DIRECT_FILES_CATALOG_SCHEMA_VERSION || + catalog.backupKind !== 'spacetimedb-data-dir-files' || + catalog.database !== database || + catalog.bucket !== bucket || + catalog.catalogId !== catalogRef.catalogId || + !Array.isArray(catalog.directories) || + !Array.isArray(catalog.files) ) { throw new Error(`files restore catalog 契约无效: ${catalogRef.objectKey}`); } - return {...catalog, symlinks: catalog.symlinks ?? []}; + return { ...catalog, symlinks: catalog.symlinks ?? [] }; } function assertDirectCatalogFile(file, index) { if ( - !file - || typeof file.path !== 'string' - || !Number.isSafeInteger(file.sizeBytes) - || file.sizeBytes < 0 - || !/^[a-f0-9]{64}$/u.test(file.sha256) - || typeof file.objectKey !== 'string' - || !file.objectKey - || !Number.isSafeInteger(file.mode) + !file || + typeof file.path !== 'string' || + !Number.isSafeInteger(file.sizeBytes) || + file.sizeBytes < 0 || + !/^[a-f0-9]{64}$/u.test(file.sha256) || + typeof file.objectKey !== 'string' || + !file.objectKey || + !Number.isSafeInteger(file.mode) ) { throw new Error(`files restore catalog 文件项无效: index=${index}`); } @@ -1905,18 +2446,21 @@ function assertDirectCatalogFile(file, index) { function assertDirectCatalogSymlink(symlink, index, restoreDir) { if ( - !symlink - || typeof symlink.path !== 'string' - || !symlink.path - || typeof symlink.target !== 'string' - || !symlink.target - || isAbsolute(symlink.target) + !symlink || + typeof symlink.path !== 'string' || + !symlink.path || + typeof symlink.target !== 'string' || + !symlink.target || + isAbsolute(symlink.target) ) { throw new Error(`files restore catalog 符号链接项无效: index=${index}`); } const destinationPath = resolve(restoreDir, symlink.path); assertSafeRelativePath(restoreDir, destinationPath); - assertSafeRelativePath(restoreDir, resolve(dirname(destinationPath), symlink.target)); + assertSafeRelativePath( + restoreDir, + resolve(dirname(destinationPath), symlink.target), + ); } async function restoreDirectFilesCatalog({ @@ -1929,8 +2473,12 @@ async function restoreDirectFilesCatalog({ }) { const resolvedRestoreDir = resolvePath(restoreDir); catalog.files.forEach(assertDirectCatalogFile); - catalog.symlinks.forEach((symlink, index) => assertDirectCatalogSymlink(symlink, index, resolvedRestoreDir)); - const totalSizeBytes = catalog.files.reduce((sum, file) => sum + BigInt(file.sizeBytes), 0n).toString(); + catalog.symlinks.forEach((symlink, index) => + assertDirectCatalogSymlink(symlink, index, resolvedRestoreDir), + ); + const totalSizeBytes = catalog.files + .reduce((sum, file) => sum + BigInt(file.sizeBytes), 0n) + .toString(); if (dryRun) { const result = { restoreDir: resolvedRestoreDir, @@ -1947,14 +2495,14 @@ async function restoreDirectFilesCatalog({ } return result; } - mkdirSync(resolvedRestoreDir, {recursive: true, mode: 0o700}); + mkdirSync(resolvedRestoreDir, { recursive: true, mode: 0o700 }); for (const directoryPath of catalog.directories) { if (directoryPath === '.') { continue; } const absolutePath = resolve(resolvedRestoreDir, directoryPath); assertSafeRelativePath(resolvedRestoreDir, absolutePath); - mkdirSync(absolutePath, {recursive: true}); + mkdirSync(absolutePath, { recursive: true }); } let downloadedCount = 0; @@ -1962,35 +2510,47 @@ async function restoreDirectFilesCatalog({ for (const [index, file] of catalog.files.entries()) { const destinationPath = resolve(resolvedRestoreDir, file.path); assertSafeRelativePath(resolvedRestoreDir, destinationPath); - mkdirSync(dirname(destinationPath), {recursive: true}); + mkdirSync(dirname(destinationPath), { recursive: true }); let reusable = false; if (existsSync(destinationPath) && lstatSync(destinationPath).isFile()) { const stat = statSync(destinationPath); - reusable = stat.size === file.sizeBytes && await sha256FileHex(destinationPath) === file.sha256; + reusable = + stat.size === file.sizeBytes && + (await sha256FileHex(destinationPath)) === file.sha256; } if (reusable) { reusedCount += 1; } else { - rmSync(destinationPath, {force: true}); - await downloadFileFn({objectKey: file.objectKey, destinationPath, uploadOptions}); + rmSync(destinationPath, { force: true }); + await downloadFileFn({ + objectKey: file.objectKey, + destinationPath, + uploadOptions, + }); const stat = statSync(destinationPath); const sha256 = await sha256FileHex(destinationPath); if (stat.size !== file.sizeBytes || sha256 !== file.sha256) { - rmSync(destinationPath, {force: true}); - throw new Error(`files restore 对象长度或 SHA-256 不一致: ${file.path}`); + rmSync(destinationPath, { force: true }); + throw new Error( + `files restore 对象长度或 SHA-256 不一致: ${file.path}`, + ); } downloadedCount += 1; } chmodSync(destinationPath, file.mode & 0o7777); - console.log(`[database-backup] files restore: ${index + 1}/${catalog.files.length} (${reusable ? 'reused' : 'downloaded'}) ${file.path}`); + console.log( + `[database-backup] files restore: ${index + 1}/${catalog.files.length} (${reusable ? 'reused' : 'downloaded'}) ${file.path}`, + ); } for (const symlink of catalog.symlinks) { const destinationPath = resolve(resolvedRestoreDir, symlink.path); assertSafeRelativePath(resolvedRestoreDir, destinationPath); - mkdirSync(dirname(destinationPath), {recursive: true}); - rmSync(destinationPath, {recursive: true, force: true}); + mkdirSync(dirname(destinationPath), { recursive: true }); + rmSync(destinationPath, { recursive: true, force: true }); symlinkSync(symlink.target, destinationPath); - console.log(`[database-backup] files restore: symlink ${symlink.path} -> ${symlink.target}`); + console.log( + `[database-backup] files restore: symlink ${symlink.path} -> ${symlink.target}`, + ); } const result = { restoreDir: resolvedRestoreDir, @@ -2018,12 +2578,25 @@ export async function restoreDirectFilesBackup({ downloadBufferFn = downloadOssBuffer, downloadFileFn = downloadOssFile, }) { - const state = readDirectFilesState(resolvePath(statePath), {database, bucket}); + const state = readDirectFilesState(resolvePath(statePath), { + database, + bucket, + }); if (!state?.latestCatalog || state.latestCatalog.mode !== 'full') { throw new Error(`files restore 缺少 full baseline catalog: ${statePath}`); } - const catalogRef = assertDirectCatalogRef(state.latestCatalog, 'full', 'latest full'); - const catalog = await loadDirectFilesCatalog({catalogRef, database, bucket, uploadOptions, downloadBufferFn}); + const catalogRef = assertDirectCatalogRef( + state.latestCatalog, + 'full', + 'latest full', + ); + const catalog = await loadDirectFilesCatalog({ + catalogRef, + database, + bucket, + uploadOptions, + downloadBufferFn, + }); return restoreDirectFilesCatalog({ catalog, restoreDir, @@ -2047,7 +2620,10 @@ export async function restoreDirectFilesLatest({ verifyFn = verifyOssObject, }) { const latestObjectKey = `${normalizeObjectPrefix(objectPrefix, database)}/latest.json`; - const latestBody = await downloadBufferFn({objectKey: latestObjectKey, uploadOptions}); + const latestBody = await downloadBufferFn({ + objectKey: latestObjectKey, + uploadOptions, + }); const latestSha256 = sha256Hex(latestBody); await verifyFn({ ...uploadOptions, @@ -2055,7 +2631,10 @@ export async function restoreDirectFilesLatest({ contentLength: latestBody.length, archiveSha256: latestSha256, }); - const latest = validateDirectFilesLatest(JSON.parse(latestBody.toString('utf8')), {database, bucket}); + const latest = validateDirectFilesLatest( + JSON.parse(latestBody.toString('utf8')), + { database, bucket }, + ); const catalogRef = latest.latestFullCatalog; await verifyFn({ ...uploadOptions, @@ -2063,7 +2642,13 @@ export async function restoreDirectFilesLatest({ contentLength: catalogRef.contentLength, archiveSha256: catalogRef.sha256, }); - const catalog = await loadDirectFilesCatalog({catalogRef, database, bucket, uploadOptions, downloadBufferFn}); + const catalog = await loadDirectFilesCatalog({ + catalogRef, + database, + bucket, + uploadOptions, + downloadBufferFn, + }); return restoreDirectFilesCatalog({ catalog, restoreDir, @@ -2093,7 +2678,12 @@ function formatOssDate(date) { function encodePath(path) { return path .split('/') - .map((segment) => encodeURIComponent(segment).replace(/[!'()*]/gu, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)) + .map((segment) => + encodeURIComponent(segment).replace( + /[!'()*]/gu, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ), + ) .join('/'); } @@ -2106,7 +2696,10 @@ function encodeQueryComponent(value) { export function buildCanonicalQuery(queries = {}) { return Object.entries(queries) - .map(([key, value]) => [encodeQueryComponent(key), value === null ? null : encodeQueryComponent(value)]) + .map(([key, value]) => [ + encodeQueryComponent(key), + value === null ? null : encodeQueryComponent(value), + ]) .sort(([leftKey, leftValue], [rightKey, rightValue]) => { if (leftKey !== rightKey) { return leftKey < rightKey ? -1 : 1; @@ -2115,7 +2708,7 @@ export function buildCanonicalQuery(queries = {}) { const right = rightValue ?? ''; return left === right ? 0 : left < right ? -1 : 1; }) - .map(([key, value]) => value === null ? key : `${key}=${value}`) + .map(([key, value]) => (value === null ? key : `${key}=${value}`)) .join('&'); } @@ -2123,13 +2716,26 @@ function canonicalHeaderValue(value) { return String(value).trim().replace(/\s+/gu, ' '); } -export function buildAuthorization({method, bucket, endpoint, objectKey, accessKeyId, accessKeySecret, headers, date, queries = {}}) { +export function buildAuthorization({ + method, + bucket, + endpoint, + objectKey, + accessKeyId, + accessKeySecret, + headers, + date, + queries = {}, +}) { const region = regionFromEndpoint(endpoint); const scopeDate = formatScopeDate(date); const scope = `${scopeDate}/${region}/${OSS_SERVICE}/${OSS_REQUEST}`; const canonicalUri = `/${encodeURIComponent(bucket)}/${encodePath(objectKey)}`; const signedHeaders = Object.fromEntries( - Object.entries(headers).map(([key, value]) => [key.toLowerCase(), canonicalHeaderValue(value)]), + Object.entries(headers).map(([key, value]) => [ + key.toLowerCase(), + canonicalHeaderValue(value), + ]), ); const canonicalHeaders = Object.entries(signedHeaders) .sort(([left], [right]) => left.localeCompare(right)) @@ -2144,8 +2750,16 @@ export function buildAuthorization({method, bucket, endpoint, objectKey, accessK additionalHeaders, UNSIGNED_PAYLOAD, ].join('\n'); - const stringToSign = [OSS_ALGORITHM, headers['x-oss-date'], scope, sha256Hex(canonicalRequest)].join('\n'); - const signature = hmac(Buffer.from(`aliyun_v4${accessKeySecret}`, 'utf8'), scopeDate); + const stringToSign = [ + OSS_ALGORITHM, + headers['x-oss-date'], + scope, + sha256Hex(canonicalRequest), + ].join('\n'); + const signature = hmac( + Buffer.from(`aliyun_v4${accessKeySecret}`, 'utf8'), + scopeDate, + ); const regionKey = hmac(signature, region); const serviceKey = hmac(regionKey, OSS_SERVICE); const signingKey = hmac(serviceKey, OSS_REQUEST); @@ -2153,7 +2767,7 @@ export function buildAuthorization({method, bucket, endpoint, objectKey, accessK return `${OSS_ALGORITHM} Credential=${accessKeyId}/${scope},AdditionalHeaders=${additionalHeaders},Signature=${finalSignature}`; } -function buildOssUrl({bucket, endpoint, objectKey, queries = {}}) { +function buildOssUrl({ bucket, endpoint, objectKey, queries = {} }) { const canonicalQuery = buildCanonicalQuery(queries); return `https://${bucket}.${endpoint}/${encodePath(objectKey)}${canonicalQuery ? `?${canonicalQuery}` : ''}`; } @@ -2162,8 +2776,11 @@ function isRetryableOssStatus(status) { return RETRYABLE_OSS_HTTP_STATUSES.has(status); } -function retryDelayMs({attempt, baseDelayMs, maxDelayMs, randomFn}) { - const ceiling = Math.min(maxDelayMs, baseDelayMs * (2 ** Math.max(0, attempt - 1))); +function retryDelayMs({ attempt, baseDelayMs, maxDelayMs, randomFn }) { + const ceiling = Math.min( + maxDelayMs, + baseDelayMs * 2 ** Math.max(0, attempt - 1), + ); return Math.floor(randomFn() * ceiling); } @@ -2215,7 +2832,7 @@ async function signedOssRequest({ retryBaseDelayMs, retryMaxDelayMs, }) { - const targetUrl = buildOssUrl({bucket, endpoint, objectKey, queries}); + const targetUrl = buildOssUrl({ bucket, endpoint, objectKey, queries }); let lastError = null; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { @@ -2237,12 +2854,12 @@ async function signedOssRequest({ date: now, queries, }); - const requestHeaders = {...signedHeaders, authorization}; + const requestHeaders = { ...signedHeaders, authorization }; if (contentLength !== undefined) { requestHeaders['content-length'] = String(contentLength); } const body = bodyFactory ? bodyFactory() : undefined; - const requestOptions = {method, headers: requestHeaders}; + const requestOptions = { method, headers: requestHeaders }; if (body !== undefined) { requestOptions.body = body; requestOptions.duplex = 'half'; @@ -2252,7 +2869,10 @@ async function signedOssRequest({ try { response = await fetchImpl(targetUrl, requestOptions); } catch (error) { - lastError = new Error(`OSS ${operation}请求失败: oss://${bucket}/${objectKey}`, {cause: error}); + lastError = new Error( + `OSS ${operation}请求失败: oss://${bucket}/${objectKey}`, + { cause: error }, + ); } if (response?.ok) { @@ -2271,16 +2891,27 @@ async function signedOssRequest({ if (!retryable || attempt >= maxAttempts) { throw lastError; } - const delayMs = retryDelayMs({attempt, baseDelayMs: retryBaseDelayMs, maxDelayMs: retryMaxDelayMs, randomFn}); - console.warn(`[database-backup] OSS ${operation}失败,${delayMs}ms 后重试 (${attempt}/${maxAttempts})`); + const delayMs = retryDelayMs({ + attempt, + baseDelayMs: retryBaseDelayMs, + maxDelayMs: retryMaxDelayMs, + randomFn, + }); + console.warn( + `[database-backup] OSS ${operation}失败,${delayMs}ms 后重试 (${attempt}/${maxAttempts})`, + ); await sleepImpl(delayMs); } - throw lastError ?? new Error(`OSS ${operation}失败: oss://${bucket}/${objectKey}`); + throw ( + lastError ?? new Error(`OSS ${operation}失败: oss://${bucket}/${objectKey}`) + ); } function readXmlTag(xml, tagName) { - const match = new RegExp(`<${tagName}>([\\s\\S]*?)`, 'u').exec(xml); + const match = new RegExp(`<${tagName}>([\\s\\S]*?)`, 'u').exec( + xml, + ); if (!match) { return ''; } @@ -2304,19 +2935,26 @@ function escapeXml(value) { function buildCompleteMultipartBody(parts) { const partXml = parts - .map(({partNumber, etag}) => [ - '', - `${partNumber}`, - `${escapeXml(etag)}`, - '', - ].join('')) + .map(({ partNumber, etag }) => + [ + '', + `${partNumber}`, + `${escapeXml(etag)}`, + '', + ].join(''), + ) .join(''); return `${partXml}`; } function resolveMultipartPartSize(fileSize, configuredPartSize) { - if (!Number.isSafeInteger(configuredPartSize) || configuredPartSize < OSS_MIN_MULTIPART_PART_SIZE_BYTES) { - throw new Error(`OSS multipart part size 必须是 >= ${OSS_MIN_MULTIPART_PART_SIZE_BYTES} 的安全整数`); + if ( + !Number.isSafeInteger(configuredPartSize) || + configuredPartSize < OSS_MIN_MULTIPART_PART_SIZE_BYTES + ) { + throw new Error( + `OSS multipart part size 必须是 >= ${OSS_MIN_MULTIPART_PART_SIZE_BYTES} 的安全整数`, + ); } const minimumForPartLimit = Math.ceil(fileSize / OSS_MAX_MULTIPART_PARTS); const partSize = Math.max(configuredPartSize, minimumForPartLimit); @@ -2326,7 +2964,11 @@ function resolveMultipartPartSize(fileSize, configuredPartSize) { return partSize; } -async function verifyUploadedObject({requestOptions, expectedContentLength, expectedArchiveSha256}) { +async function verifyUploadedObject({ + requestOptions, + expectedContentLength, + expectedArchiveSha256, +}) { const response = await signedOssRequest({ ...requestOptions, method: 'HEAD', @@ -2344,17 +2986,27 @@ async function verifyUploadedObject({requestOptions, expectedContentLength, expe } const remoteContentLength = Number(effectiveLengthHeader); if (remoteContentLength !== expectedContentLength) { - throw new Error(`OSS HEAD 验证长度不一致: local=${expectedContentLength}, remote=${remoteContentLength}`); + throw new Error( + `OSS HEAD 验证长度不一致: local=${expectedContentLength}, remote=${remoteContentLength}`, + ); } const remoteArchiveSha256 = String( - response.headers.get('x-oss-meta-file-sha256') - ?? response.headers.get('x-oss-meta-archive-sha256') - ?? '', - ).trim().toLowerCase(); + response.headers.get('x-oss-meta-file-sha256') ?? + response.headers.get('x-oss-meta-archive-sha256') ?? + '', + ) + .trim() + .toLowerCase(); if (remoteArchiveSha256 !== expectedArchiveSha256) { - throw new Error(`OSS HEAD 验证 SHA-256 不一致: local=${expectedArchiveSha256}, remote=${remoteArchiveSha256 || ''}`); + throw new Error( + `OSS HEAD 验证 SHA-256 不一致: local=${expectedArchiveSha256}, remote=${remoteArchiveSha256 || ''}`, + ); } - return {verifiedAt: new Date().toISOString(), remoteContentLength, remoteArchiveSha256}; + return { + verifiedAt: new Date().toISOString(), + remoteContentLength, + remoteArchiveSha256, + }; } export async function verifyOssObject({ @@ -2388,22 +3040,28 @@ export async function verifyOssObject({ return verifyUploadedObject({ requestOptions, expectedContentLength: Number(contentLength), - expectedArchiveSha256: String(archiveSha256 ?? '').trim().toLowerCase(), + expectedArchiveSha256: String(archiveSha256 ?? '') + .trim() + .toLowerCase(), }); } -async function abortMultipartUpload({requestOptions, uploadId}) { +async function abortMultipartUpload({ requestOptions, uploadId }) { try { await signedOssRequest({ ...requestOptions, method: 'DELETE', - queries: {uploadId}, + queries: { uploadId }, operation: 'AbortMultipartUpload', maxAttempts: Math.min(2, requestOptions.maxAttempts), }); - console.warn(`[database-backup] 已清理失败的 multipart upload: ${uploadId}`); + console.warn( + `[database-backup] 已清理失败的 multipart upload: ${uploadId}`, + ); } catch (error) { - console.warn(`[database-backup] 清理 multipart upload 失败: ${error.message}`); + console.warn( + `[database-backup] 清理 multipart upload 失败: ${error.message}`, + ); } } @@ -2430,9 +3088,12 @@ export async function uploadArchive({ }) { const fileStat = statSync(archivePath); if (!fileStat.isFile() || (!allowEmpty && fileStat.size <= 0)) { - throw new Error(`待上传备份必须是${allowEmpty ? '' : '非空'}普通文件: ${archivePath}`); + throw new Error( + `待上传备份必须是${allowEmpty ? '' : '非空'}普通文件: ${archivePath}`, + ); } - const verifiedArchiveSha256 = archiveSha256 || await sha256FileHex(archivePath); + const verifiedArchiveSha256 = + archiveSha256 || (await sha256FileHex(archivePath)); if (!/^[a-f0-9]{64}$/u.test(verifiedArchiveSha256)) { throw new Error(`归档 SHA-256 无效: ${verifiedArchiveSha256}`); } @@ -2465,20 +3126,36 @@ export async function uploadArchive({ bodyFactory: () => Buffer.alloc(0), operation: '上传空文件', }); - const verification = await verifyUploadedObject({requestOptions, expectedContentLength: 0, expectedArchiveSha256: verifiedArchiveSha256}); - return {bucket, objectKey, contentLength: 0, archiveSha256: verifiedArchiveSha256, etag: '', uploadMode: 'single', partCount: 1, partSizeBytes: 0, verifiedAt: verification.verifiedAt}; + const verification = await verifyUploadedObject({ + requestOptions, + expectedContentLength: 0, + expectedArchiveSha256: verifiedArchiveSha256, + }); + return { + bucket, + objectKey, + contentLength: 0, + archiveSha256: verifiedArchiveSha256, + etag: '', + uploadMode: 'single', + partCount: 1, + partSizeBytes: 0, + verifiedAt: verification.verifiedAt, + }; } const partSize = resolveMultipartPartSize(fileStat.size, partSizeBytes); const partCount = Math.ceil(fileStat.size / partSize); let uploadId = ''; let uploadCompleted = false; - console.log(`[database-backup] multipart 上传 OSS: oss://${bucket}/${objectKey} (${partCount} parts)`); + console.log( + `[database-backup] multipart 上传 OSS: oss://${bucket}/${objectKey} (${partCount} parts)`, + ); try { const initiateResponse = await signedOssRequest({ ...requestOptions, method: 'POST', - queries: {uploads: null}, + queries: { uploads: null }, headers: { 'content-type': contentType, 'x-oss-meta-archive-sha256': verifiedArchiveSha256, @@ -2501,21 +3178,25 @@ export async function uploadArchive({ const response = await signedOssRequest({ ...requestOptions, method: 'PUT', - queries: {partNumber, uploadId}, - headers: {'content-type': 'application/octet-stream'}, + queries: { partNumber, uploadId }, + headers: { 'content-type': 'application/octet-stream' }, contentLength, bodyFactory: () => { - const stream = createReadStream(archivePath, {start, end}); + const stream = createReadStream(archivePath, { start, end }); return bandwidthLimiter ? bandwidthLimiter.wrap(stream) : stream; }, operation: `UploadPart ${partNumber}/${partCount}`, }); const etag = response.headers.get('etag'); if (!etag) { - throw new Error(`OSS UploadPart ${partNumber}/${partCount} 响应缺少 ETag`); + throw new Error( + `OSS UploadPart ${partNumber}/${partCount} 响应缺少 ETag`, + ); } - parts.push({partNumber, etag}); - console.log(`[database-backup] multipart 进度: ${partNumber}/${partCount}`); + parts.push({ partNumber, etag }); + console.log( + `[database-backup] multipart 进度: ${partNumber}/${partCount}`, + ); } const completeBody = buildCompleteMultipartBody(parts); @@ -2524,19 +3205,25 @@ export async function uploadArchive({ completeResponse = await signedOssRequest({ ...requestOptions, method: 'POST', - queries: {uploadId}, - headers: {'content-type': 'application/xml'}, + queries: { uploadId }, + headers: { 'content-type': 'application/xml' }, contentLength: Buffer.byteLength(completeBody), bodyFactory: () => completeBody, operation: 'CompleteMultipartUpload', }); const completeResponseText = await completeResponse.text(); if (/)/u.test(completeResponseText)) { - throw new Error(`OSS CompleteMultipartUpload 返回错误: ${completeResponseText.slice(0, 500)}`); + throw new Error( + `OSS CompleteMultipartUpload 返回错误: ${completeResponseText.slice(0, 500)}`, + ); } } catch (completeError) { try { - await verifyUploadedObject({requestOptions, expectedContentLength: fileStat.size, expectedArchiveSha256: verifiedArchiveSha256}); + await verifyUploadedObject({ + requestOptions, + expectedContentLength: fileStat.size, + expectedArchiveSha256: verifiedArchiveSha256, + }); completeResponse = null; } catch { throw completeError; @@ -2562,7 +3249,7 @@ export async function uploadArchive({ }; } catch (error) { if (uploadId && !uploadCompleted) { - await abortMultipartUpload({requestOptions, uploadId}); + await abortMultipartUpload({ requestOptions, uploadId }); } throw error; } @@ -2590,7 +3277,9 @@ export async function uploadDirectFile({ }) { const fileStat = statSync(archivePath); if (!fileStat.isFile() || (!allowEmpty && fileStat.size <= 0)) { - throw new Error(`待上传备份必须是${allowEmpty ? '' : '非空'}普通文件: ${archivePath}`); + throw new Error( + `待上传备份必须是${allowEmpty ? '' : '非空'}普通文件: ${archivePath}`, + ); } if (fileStat.size > DIRECT_FILES_SINGLE_PUT_MAX_BYTES) { return uploadArchive({ @@ -2614,7 +3303,8 @@ export async function uploadDirectFile({ bandwidthLimiter, }); } - const verifiedArchiveSha256 = archiveSha256 || await sha256FileHex(archivePath); + const verifiedArchiveSha256 = + archiveSha256 || (await sha256FileHex(archivePath)); if (!/^[a-f0-9]{64}$/u.test(verifiedArchiveSha256)) { throw new Error(`归档 SHA-256 无效: ${verifiedArchiveSha256}`); } @@ -2724,10 +3414,15 @@ export async function uploadManifestFile({ expectedContentLength: body.length, expectedArchiveSha256: archiveSha256, }); - return {objectKey, contentLength: body.length, archiveSha256, verifiedAt: verification.verifiedAt}; + return { + objectKey, + contentLength: body.length, + archiveSha256, + verifiedAt: verification.verifiedAt, + }; } -function uploadedManifestPayload({manifest, database, result}) { +function uploadedManifestPayload({ manifest, database, result }) { return { ...manifest, database, @@ -2761,8 +3456,12 @@ export async function uploadHistoryArchiveWithCleanup({ ...uploadOptions, backupKind: 'spacetimedb-history', }); - const uploadedManifest = uploadedManifestPayload({manifest, database: manifest.database, result}); - writeManifest({manifestPath, payload: uploadedManifest}); + const uploadedManifest = uploadedManifestPayload({ + manifest, + database: manifest.database, + result, + }); + writeManifest({ manifestPath, payload: uploadedManifest }); const manifestUpload = await manifestUploadFn({ manifestPath, ...uploadOptions, @@ -2771,13 +3470,15 @@ export async function uploadHistoryArchiveWithCleanup({ uploadedManifest.manifestVerifiedAt = manifestUpload.verifiedAt; uploadedManifest.manifestContentLength = manifestUpload.contentLength; uploadedManifest.manifestArchiveSha256 = manifestUpload.archiveSha256; - writeManifest({manifestPath, payload: uploadedManifest}); + writeManifest({ manifestPath, payload: uploadedManifest }); let state = validateHistoryState(readManifest(statePath), { database: uploadedManifest.database, dataDir: uploadedManifest.dataDir, }); if (state.baseline.id !== uploadedManifest.baselineId) { - throw new Error(`history manifest baselineId 与 state 不匹配: manifest=${uploadedManifest.baselineId}, state=${state.baseline.id}`); + throw new Error( + `history manifest baselineId 与 state 不匹配: manifest=${uploadedManifest.baselineId}, state=${state.baseline.id}`, + ); } await verifyFn({ ...uploadOptions, @@ -2814,69 +3515,111 @@ export async function uploadHistoryArchiveWithCleanup({ status: 'cleaned', cleanedAt: new Date().toISOString(), }); - return {result, uploadedManifest, cleanup, state}; + return { result, uploadedManifest, cleanup, state }; } -export function discoverDeferredArchiveUploads({workDir, database, includeUploaded = false}) { +export function discoverDeferredArchiveUploads({ + workDir, + database, + includeUploaded = false, +}) { const resolvedWorkDir = resolvePath(workDir); if (!existsSync(resolvedWorkDir)) { - return {archives: [], missingArchives: []}; + return { archives: [], missingArchives: [] }; } const archives = []; const missingArchives = []; const manifestSuffix = '.tar.gz.manifest.json'; const expectedDatabase = String(database || '').trim(); - const entries = readdirSync(resolvedWorkDir, {withFileTypes: true}) - .filter((candidate) => candidate.isFile() && candidate.name.endsWith(manifestSuffix)) + const entries = readdirSync(resolvedWorkDir, { withFileTypes: true }) + .filter( + (candidate) => + candidate.isFile() && candidate.name.endsWith(manifestSuffix), + ) .sort((left, right) => left.name.localeCompare(right.name, 'en')); for (const entry of entries) { const manifestPath = join(resolvedWorkDir, entry.name); const manifest = readManifest(manifestPath); const uploadStatus = String(manifest.uploadStatus || '').trim(); - if (!['deferred', 'pending'].includes(uploadStatus) && !(includeUploaded && uploadStatus === 'uploaded')) { + if ( + !['deferred', 'pending'].includes(uploadStatus) && + !(includeUploaded && uploadStatus === 'uploaded') + ) { continue; } - if (expectedDatabase && String(manifest.database || '').trim() !== expectedDatabase) { + if ( + expectedDatabase && + String(manifest.database || '').trim() !== expectedDatabase + ) { continue; } if (!manifest.archivePath) { throw new Error(`deferred 备份清单缺少 archivePath: ${manifestPath}`); } const archivePath = resolvePath(manifest.archivePath); - if (dirname(archivePath) !== resolvedWorkDir || manifestPath !== `${archivePath}.manifest.json`) { + if ( + dirname(archivePath) !== resolvedWorkDir || + manifestPath !== `${archivePath}.manifest.json` + ) { throw new Error(`deferred 备份路径与清单不匹配: ${manifestPath}`); } - const candidate = {archivePath, manifestPath, manifest}; + const candidate = { archivePath, manifestPath, manifest }; if (existsSync(archivePath)) { const archiveStat = lstatSync(archivePath); if (!archiveStat.isFile() || archiveStat.isSymbolicLink()) { - throw new Error(`deferred 备份归档必须是非符号链接的普通文件: ${archivePath}`); + throw new Error( + `deferred 备份归档必须是非符号链接的普通文件: ${archivePath}`, + ); } archives.push(candidate); } else { missingArchives.push(candidate); } } - return {archives, missingArchives}; + return { archives, missingArchives }; } -async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, accessKeySecret, objectPrefix, bandwidthLimiter}) { +async function uploadExistingArchive({ + args, + env, + bucket, + endpoint, + accessKeyId, + accessKeySecret, + objectPrefix, + bandwidthLimiter, +}) { const archivePath = resolvePath(args.uploadArchive); if (!existsSync(archivePath)) { throw new Error(`待上传备份文件不存在: ${archivePath}`); } - const manifestPath = resolvePath(args.manifestFile || `${archivePath}.manifest.json`); + const manifestPath = resolvePath( + args.manifestFile || `${archivePath}.manifest.json`, + ); const manifest = existsSync(manifestPath) ? readManifest(manifestPath) : {}; - const dataDir = firstNonEmpty(manifest.dataDir, env.GENARRATIVE_DATABASE_BACKUP_DATA_DIR, DEFAULT_PRODUCTION_DATA_DIR); - const database = firstNonEmpty(args.database, manifest.database, env.GENARRATIVE_SPACETIME_DATABASE, basename(dataDir)); - const objectKey = firstNonEmpty(args.objectKey, manifest.objectKey, buildBackupNames({database, dataDir, objectPrefix}).objectKey); + const dataDir = firstNonEmpty( + manifest.dataDir, + env.GENARRATIVE_DATABASE_BACKUP_DATA_DIR, + DEFAULT_PRODUCTION_DATA_DIR, + ); + const database = firstNonEmpty( + args.database, + manifest.database, + env.GENARRATIVE_SPACETIME_DATABASE, + basename(dataDir), + ); + const objectKey = firstNonEmpty( + args.objectKey, + manifest.objectKey, + buildBackupNames({ database, dataDir, objectPrefix }).objectKey, + ); if (manifest.backupKind !== 'spacetimedb-history') { manifest.backupKind = 'spacetimedb-data-dir'; manifest.baselineStatePath = firstNonEmpty( manifest.baselineStatePath, - historyStatePath({args, env, workDir: dirname(archivePath), database}), + historyStatePath({ args, env, workDir: dirname(archivePath), database }), ); } @@ -2888,10 +3631,12 @@ async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, return; } - const statePath = resolvePath(firstNonEmpty( - manifest.baselineStatePath, - historyStatePath({args, env, workDir: dirname(archivePath), database}), - )); + const statePath = resolvePath( + firstNonEmpty( + manifest.baselineStatePath, + historyStatePath({ args, env, workDir: dirname(archivePath), database }), + ), + ); let result; let uploadedAt; if (manifest.backupKind === 'spacetimedb-history') { @@ -2900,16 +3645,37 @@ async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, manifestPath, manifest, statePath, - uploadOptions: {bucket, endpoint, objectKey, accessKeyId, accessKeySecret, bandwidthLimiter}, + uploadOptions: { + bucket, + endpoint, + objectKey, + accessKeyId, + accessKeySecret, + bandwidthLimiter, + }, }); result = historyResult.result; uploadedAt = historyResult.uploadedManifest.uploadedAt; - console.log(`[database-backup] history 上传并清理完成: ${JSON.stringify(historyResult.cleanup)}`); + console.log( + `[database-backup] history 上传并清理完成: ${JSON.stringify(historyResult.cleanup)}`, + ); } else { - result = await uploadArchive({archivePath, bucket, endpoint, objectKey, accessKeyId, accessKeySecret, bandwidthLimiter}); - const uploadedManifest = uploadedManifestPayload({manifest, database, result}); + result = await uploadArchive({ + archivePath, + bucket, + endpoint, + objectKey, + accessKeyId, + accessKeySecret, + bandwidthLimiter, + }); + const uploadedManifest = uploadedManifestPayload({ + manifest, + database, + result, + }); uploadedAt = uploadedManifest.uploadedAt; - writeManifest({manifestPath, payload: uploadedManifest}); + writeManifest({ manifestPath, payload: uploadedManifest }); const manifestUpload = await uploadManifestFile({ manifestPath, bucket, @@ -2922,50 +3688,81 @@ async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, uploadedManifest.manifestVerifiedAt = manifestUpload.verifiedAt; uploadedManifest.manifestContentLength = manifestUpload.contentLength; uploadedManifest.manifestArchiveSha256 = manifestUpload.archiveSha256; - writeManifest({manifestPath, payload: uploadedManifest}); + writeManifest({ manifestPath, payload: uploadedManifest }); const previousState = existsSync(statePath) - ? validateHistoryState(readManifest(statePath), {database, dataDir}) + ? validateHistoryState(readManifest(statePath), { database, dataDir }) : null; - const baseline = normalizeUploadedBaselineManifest(uploadedManifest, {database, dataDir}); - writeBaselineState({statePath, baseline, previousState}); + const baseline = normalizeUploadedBaselineManifest(uploadedManifest, { + database, + dataDir, + }); + writeBaselineState({ statePath, baseline, previousState }); console.log(`[database-backup] 已写入 baseline state: ${statePath}`); } console.log(`[database-backup] 上传完成: ${JSON.stringify(result)}`); if (args.resultFile) { - writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({archivePath, manifestPath, statePath, ...result, uploadedAt}, null, 2)}\n`, 'utf8'); + writeFileSync( + resolvePath(args.resultFile), + `${JSON.stringify({ archivePath, manifestPath, statePath, ...result, uploadedAt }, null, 2)}\n`, + 'utf8', + ); } - const keepLocal = args.keepLocal || String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '').trim().toLowerCase() === 'true'; + const keepLocal = + args.keepLocal || + String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '') + .trim() + .toLowerCase() === 'true'; if (!keepLocal) { - rmSync(archivePath, {force: true}); - rmSync(manifestPath, {force: true}); - console.log('[database-backup] 已删除本地临时备份文件;如需保留请设置 --keep-local。'); + rmSync(archivePath, { force: true }); + rmSync(manifestPath, { force: true }); + console.log( + '[database-backup] 已删除本地临时备份文件;如需保留请设置 --keep-local。', + ); } else { console.log(`[database-backup] 已保留本地备份: ${archivePath}`); console.log(`[database-backup] 已保留备份清单: ${manifestPath}`); } } -async function uploadDeferredArchives({args, env, bucket, endpoint, accessKeyId, accessKeySecret, objectPrefix, database, bandwidthLimiter}) { +async function uploadDeferredArchives({ + args, + env, + bucket, + endpoint, + accessKeyId, + accessKeySecret, + objectPrefix, + database, + bandwidthLimiter, +}) { const workDir = resolvePath(args.uploadDeferredDir); - const keepLocal = args.keepLocal || String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '').trim().toLowerCase() === 'true'; - const {archives, missingArchives} = discoverDeferredArchiveUploads({ + const keepLocal = + args.keepLocal || + String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '') + .trim() + .toLowerCase() === 'true'; + const { archives, missingArchives } = discoverDeferredArchiveUploads({ workDir, database, includeUploaded: !keepLocal, }); - for (const {manifestPath} of missingArchives) { - console.warn(`[database-backup] deferred 清单对应的本地归档不存在,跳过: ${manifestPath}`); + for (const { manifestPath } of missingArchives) { + console.warn( + `[database-backup] deferred 清单对应的本地归档不存在,跳过: ${manifestPath}`, + ); } if (archives.length === 0) { console.log(`[database-backup] 没有可补偿的本地归档: ${workDir}`); return; } - console.log(`[database-backup] 开始串行上传待补偿本地归档: count=${archives.length}`); - for (const {archivePath, manifestPath} of archives) { + console.log( + `[database-backup] 开始串行上传待补偿本地归档: count=${archives.length}`, + ); + for (const { archivePath, manifestPath } of archives) { await uploadExistingArchive({ - args: {...args, uploadArchive: archivePath, manifestFile: manifestPath}, + args: { ...args, uploadArchive: archivePath, manifestFile: manifestPath }, env, bucket, endpoint, @@ -2975,17 +3772,29 @@ async function uploadDeferredArchives({args, env, bucket, endpoint, accessKeyId, bandwidthLimiter, }); } - console.log(`[database-backup] 待补偿本地归档上传完成: count=${archives.length}`); + console.log( + `[database-backup] 待补偿本地归档上传完成: count=${archives.length}`, + ); } -async function publishExistingManifest({args, bucket, endpoint, accessKeyId, accessKeySecret, bandwidthLimiter}) { +async function publishExistingManifest({ + args, + bucket, + endpoint, + accessKeyId, + accessKeySecret, + bandwidthLimiter, +}) { const manifestPath = resolvePath(args.publishManifest); const manifest = readManifest(manifestPath); if (manifest.uploadStatus !== 'uploaded' || !manifest.objectKey) { - throw new Error('只允许发布 uploadStatus=uploaded 且包含 objectKey 的备份 manifest。'); + throw new Error( + '只允许发布 uploadStatus=uploaded 且包含 objectKey 的备份 manifest。', + ); } - manifest.manifestObjectKey = manifest.manifestObjectKey || `${manifest.objectKey}.manifest.json`; - writeManifest({manifestPath, payload: manifest}); + manifest.manifestObjectKey = + manifest.manifestObjectKey || `${manifest.objectKey}.manifest.json`; + writeManifest({ manifestPath, payload: manifest }); const result = await uploadManifestFile({ manifestPath, bucket, @@ -2998,16 +3807,28 @@ async function publishExistingManifest({args, bucket, endpoint, accessKeyId, acc manifest.manifestVerifiedAt = result.verifiedAt; manifest.manifestContentLength = result.contentLength; manifest.manifestArchiveSha256 = result.archiveSha256; - writeManifest({manifestPath, payload: manifest}); - console.log(`[database-backup] manifest 上传并验真完成: ${JSON.stringify(result)}`); + writeManifest({ manifestPath, payload: manifest }); + console.log( + `[database-backup] manifest 上传并验真完成: ${JSON.stringify(result)}`, + ); } -export async function resumeUploadedHistoryBatch({statePath, state, dataDir, verificationOptions, verifyFn = verifyOssObject}) { - const pendingBatch = state.batches.find((batch) => batch.status === 'uploaded'); +export async function resumeUploadedHistoryBatch({ + statePath, + state, + dataDir, + verificationOptions, + verifyFn = verifyOssObject, +}) { + const pendingBatch = state.batches.find( + (batch) => batch.status === 'uploaded', + ); if (!pendingBatch) { return state; } - console.log(`[database-backup] 重试已上传 history 批次的本地清理: ${pendingBatch.batchId}`); + console.log( + `[database-backup] 重试已上传 history 批次的本地清理: ${pendingBatch.batchId}`, + ); await verifyFn({ ...verificationOptions, objectKey: pendingBatch.objectKey, @@ -3020,7 +3841,10 @@ export async function resumeUploadedHistoryBatch({statePath, state, dataDir, ver contentLength: pendingBatch.manifestContentLength, archiveSha256: pendingBatch.manifestArchiveSha256, }); - const cleanup = cleanupHistoryCandidates({dataDir, candidates: pendingBatch.candidates}); + const cleanup = cleanupHistoryCandidates({ + dataDir, + candidates: pendingBatch.candidates, + }); const manifest = { batchId: pendingBatch.batchId, uploadedAt: pendingBatch.uploadedAt, @@ -3047,7 +3871,9 @@ export async function resumeUploadedHistoryBatch({statePath, state, dataDir, ver status: 'cleaned', cleanedAt: new Date().toISOString(), }); - console.log(`[database-backup] 已完成 history 清理重试: ${JSON.stringify(cleanup)}`); + console.log( + `[database-backup] 已完成 history 清理重试: ${JSON.stringify(cleanup)}`, + ); return nextState; } @@ -3065,10 +3891,18 @@ async function runHistoryBackup({ keepLocal, bandwidthLimiter, }) { - const statePath = historyStatePath({args, env, workDir, database}); - let state = loadOrImportHistoryState({args, env, statePath, database, dataDir}); + const statePath = historyStatePath({ args, env, workDir, database }); + let state = loadOrImportHistoryState({ + args, + env, + statePath, + database, + dataDir, + }); if (!args.dryRun && !args.deferUpload) { - console.log(`[database-backup] 重新验真 full baseline: oss://${state.baseline.bucket}/${state.baseline.objectKey}`); + console.log( + `[database-backup] 重新验真 full baseline: oss://${state.baseline.bucket}/${state.baseline.objectKey}`, + ); await verifyOssObject({ bucket: state.baseline.bucket, endpoint, @@ -3091,17 +3925,27 @@ async function runHistoryBackup({ statePath, state, dataDir, - verificationOptions: {bucket, endpoint, accessKeyId, accessKeySecret}, + verificationOptions: { bucket, endpoint, accessKeyId, accessKeySecret }, }); } - const plan = discoverHistoryPlan({dataDir}); - console.log(`[database-backup] history replicas: ${JSON.stringify(plan.replicas)}`); - console.log(`[database-backup] history 候选: count=${plan.candidates.length}, size=${formatBytes(plan.totalSizeBytes)}`); + const plan = discoverHistoryPlan({ dataDir }); + console.log( + `[database-backup] history replicas: ${JSON.stringify(plan.replicas)}`, + ); + console.log( + `[database-backup] history 候选: count=${plan.candidates.length}, size=${formatBytes(plan.totalSizeBytes)}`, + ); if (args.resultFile) { - writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({statePath, baseline: state.baseline, ...plan}, null, 2)}\n`, 'utf8'); + writeFileSync( + resolvePath(args.resultFile), + `${JSON.stringify({ statePath, baseline: state.baseline, ...plan }, null, 2)}\n`, + 'utf8', + ); } if (args.dryRun) { - console.log('[database-backup] history dry-run,仅输出安全候选,不打包、上传或删除。'); + console.log( + '[database-backup] history dry-run,仅输出安全候选,不打包、上传或删除。', + ); return; } if (plan.candidates.length === 0) { @@ -3109,9 +3953,14 @@ async function runHistoryBackup({ return; } - assertSufficientHistoryWorkDirSpace({historySizeBytes: plan.totalSizeBytes, workDir, args, env}); - const batchId = historyBatchId({baselineId: state.baseline.id, plan}); - const {fileName, objectKey} = buildHistoryNames({ + assertSufficientHistoryWorkDirSpace({ + historySizeBytes: plan.totalSizeBytes, + workDir, + args, + env, + }); + const batchId = historyBatchId({ baselineId: state.baseline.id, plan }); + const { fileName, objectKey } = buildHistoryNames({ database, objectPrefix, baselineId: state.baseline.id, @@ -3136,7 +3985,7 @@ async function runHistoryBackup({ totalSizeBytes: plan.totalSizeBytes, uploadStatus: args.deferUpload ? 'deferred' : 'pending', }; - writeManifest({manifestPath, payload: manifest}); + writeManifest({ manifestPath, payload: manifest }); createHistoryArchive({ dataDir, workDir, @@ -3146,9 +3995,15 @@ async function runHistoryBackup({ }); if (args.deferUpload) { - console.log(`[database-backup] 已生成 history 归档,延后上传且未清理源文件: ${archivePath}`); + console.log( + `[database-backup] 已生成 history 归档,延后上传且未清理源文件: ${archivePath}`, + ); if (args.resultFile) { - writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({archivePath, manifestPath, statePath, bucket, objectKey, batchId}, null, 2)}\n`, 'utf8'); + writeFileSync( + resolvePath(args.resultFile), + `${JSON.stringify({ archivePath, manifestPath, statePath, bucket, objectKey, batchId }, null, 2)}\n`, + 'utf8', + ); } return; } @@ -3158,22 +4013,39 @@ async function runHistoryBackup({ manifestPath, manifest, statePath, - uploadOptions: {bucket, endpoint, objectKey, accessKeyId, accessKeySecret, bandwidthLimiter}, + uploadOptions: { + bucket, + endpoint, + objectKey, + accessKeyId, + accessKeySecret, + bandwidthLimiter, + }, }); - console.log(`[database-backup] history 上传并清理完成: ${JSON.stringify(historyResult.cleanup)}`); + console.log( + `[database-backup] history 上传并清理完成: ${JSON.stringify(historyResult.cleanup)}`, + ); if (args.resultFile) { - writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({ - archivePath, - manifestPath, - statePath, - batchId, - ...historyResult.result, - uploadedAt: historyResult.uploadedManifest.uploadedAt, - }, null, 2)}\n`, 'utf8'); + writeFileSync( + resolvePath(args.resultFile), + `${JSON.stringify( + { + archivePath, + manifestPath, + statePath, + batchId, + ...historyResult.result, + uploadedAt: historyResult.uploadedManifest.uploadedAt, + }, + null, + 2, + )}\n`, + 'utf8', + ); } if (!keepLocal) { - rmSync(archivePath, {force: true}); - rmSync(manifestPath, {force: true}); + rmSync(archivePath, { force: true }); + rmSync(manifestPath, { force: true }); console.log('[database-backup] 已删除本地 history 临时归档和清单。'); } } @@ -3181,44 +4053,96 @@ async function runHistoryBackup({ async function main() { const args = parseArgs(process.argv.slice(2)); const env = loadEffectiveEnv(args.envFiles); - const isProductionLike = existsSync(DEFAULT_PRODUCTION_DATA_DIR) && process.platform !== 'win32'; - const dataDir = resolvePath(firstNonEmpty( - args.dataDir, - env.GENARRATIVE_DATABASE_BACKUP_DATA_DIR, - isProductionLike ? DEFAULT_PRODUCTION_DATA_DIR : DEFAULT_LOCAL_DATA_DIR, - )); - const workDir = resolvePath(firstNonEmpty( - args.workDir, - args.uploadDeferredDir, - env.GENARRATIVE_DATABASE_BACKUP_WORK_DIR, - isProductionLike ? DEFAULT_PRODUCTION_WORK_DIR : DEFAULT_LOCAL_WORK_DIR, - )); - const bucket = firstNonEmpty(args.bucket, env.GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET, env.ALIYUN_OSS_BUCKET); - const endpoint = normalizeEndpoint(firstNonEmpty(args.endpoint, env.GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT, env.ALIYUN_OSS_ENDPOINT)); - const accessKeyId = firstNonEmpty(args.accessKeyId, env.GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID, env.ALIYUN_OSS_ACCESS_KEY_ID); - const accessKeySecret = firstNonEmpty(args.accessKeySecret, env.GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET, env.ALIYUN_OSS_ACCESS_KEY_SECRET); - const objectPrefix = firstNonEmpty(args.objectPrefix, env.GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX, 'database-backups'); - const database = firstNonEmpty(args.database, env.GENARRATIVE_SPACETIME_DATABASE, basename(dataDir)); - const keepLocal = args.keepLocal || String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '').trim().toLowerCase() === 'true'; - const storageFormat = firstNonEmpty(args.storageFormat, env.GENARRATIVE_DATABASE_BACKUP_STORAGE_FORMAT, 'archive'); - const directFilesConcurrency = parseDirectFilesConcurrency(env.GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY); - const uploadBandwidthLimiter = createUploadBandwidthLimiter(env.GENARRATIVE_DATABASE_BACKUP_UPLOAD_MAX_BYTES_PER_SECOND); + const isProductionLike = + existsSync(DEFAULT_PRODUCTION_DATA_DIR) && process.platform !== 'win32'; + const dataDir = resolvePath( + firstNonEmpty( + args.dataDir, + env.GENARRATIVE_DATABASE_BACKUP_DATA_DIR, + isProductionLike ? DEFAULT_PRODUCTION_DATA_DIR : DEFAULT_LOCAL_DATA_DIR, + ), + ); + const workDir = resolvePath( + firstNonEmpty( + args.workDir, + args.uploadDeferredDir, + env.GENARRATIVE_DATABASE_BACKUP_WORK_DIR, + isProductionLike ? DEFAULT_PRODUCTION_WORK_DIR : DEFAULT_LOCAL_WORK_DIR, + ), + ); + const bucket = firstNonEmpty( + args.bucket, + env.GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET, + env.ALIYUN_OSS_BUCKET, + ); + const endpoint = normalizeEndpoint( + firstNonEmpty( + args.endpoint, + env.GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT, + env.ALIYUN_OSS_ENDPOINT, + ), + ); + const accessKeyId = firstNonEmpty( + args.accessKeyId, + env.GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID, + env.ALIYUN_OSS_ACCESS_KEY_ID, + ); + const accessKeySecret = firstNonEmpty( + args.accessKeySecret, + env.GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET, + env.ALIYUN_OSS_ACCESS_KEY_SECRET, + ); + const objectPrefix = firstNonEmpty( + args.objectPrefix, + env.GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX, + 'database-backups', + ); + const database = firstNonEmpty( + args.database, + env.GENARRATIVE_SPACETIME_DATABASE, + basename(dataDir), + ); + const keepLocal = + args.keepLocal || + String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '') + .trim() + .toLowerCase() === 'true'; + const storageFormat = firstNonEmpty( + args.storageFormat, + env.GENARRATIVE_DATABASE_BACKUP_STORAGE_FORMAT, + 'archive', + ); + const directFilesConcurrency = parseDirectFilesConcurrency( + env.GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY, + ); + const uploadBandwidthLimiter = createUploadBandwidthLimiter( + env.GENARRATIVE_DATABASE_BACKUP_UPLOAD_MAX_BYTES_PER_SECOND, + ); if (!['full', 'history'].includes(args.mode)) { throw new Error(`--mode 只能是 full 或 history,实际: ${args.mode}`); } if (!['archive', 'files'].includes(storageFormat)) { - throw new Error(`--storage-format 只能是 archive 或 files,实际: ${storageFormat}`); + throw new Error( + `--storage-format 只能是 archive 或 files,实际: ${storageFormat}`, + ); } - for (const [label, value] of Object.entries({bucket, endpoint, accessKeyId, accessKeySecret})) { + for (const [label, value] of Object.entries({ + bucket, + endpoint, + accessKeyId, + accessKeySecret, + })) { if (!value) { throw new Error(`缺少 ${label} 配置`); } } if (args.restoreFilesState && args.restoreFilesLatest) { - throw new Error('--restore-files-state 与 --restore-files-latest 不能同时使用。'); + throw new Error( + '--restore-files-state 与 --restore-files-latest 不能同时使用。', + ); } if (args.restoreFilesState) { if (!args.restoreDir) { @@ -3229,7 +4153,7 @@ async function main() { restoreDir: args.restoreDir, database, bucket, - uploadOptions: {bucket, endpoint, accessKeyId, accessKeySecret}, + uploadOptions: { bucket, endpoint, accessKeyId, accessKeySecret }, resultFile: args.resultFile, dryRun: args.dryRun, }); @@ -3244,26 +4168,35 @@ async function main() { database, bucket, objectPrefix, - uploadOptions: {bucket, endpoint, accessKeyId, accessKeySecret}, + uploadOptions: { bucket, endpoint, accessKeyId, accessKeySecret }, resultFile: args.resultFile, dryRun: args.dryRun, }); return; } if (args.restoreDir) { - throw new Error('--restore-dir 只能与 --restore-files-state 或 --restore-files-latest 一起使用。'); + throw new Error( + '--restore-dir 只能与 --restore-files-state 或 --restore-files-latest 一起使用。', + ); } if (args.uploadArchive && args.uploadDeferredDir) { throw new Error('--upload-archive 与 --upload-deferred-dir 不能同时使用。'); } if (!args.dryRun) { - const lockPath = acquireBackupLock({workDir, database}); + const lockPath = acquireBackupLock({ workDir, database }); console.log(`[database-backup] 已获取进程锁: ${lockPath}`); } if (args.publishManifest) { - await publishExistingManifest({args, bucket, endpoint, accessKeyId, accessKeySecret, bandwidthLimiter: uploadBandwidthLimiter}); + await publishExistingManifest({ + args, + bucket, + endpoint, + accessKeyId, + accessKeySecret, + bandwidthLimiter: uploadBandwidthLimiter, + }); return; } @@ -3298,16 +4231,24 @@ async function main() { if (storageFormat === 'files') { if (args.deferUpload) { - throw new Error('files 模式无需本地归档且不支持 --defer-upload;失败后使用同一 work-dir 重跑即可续传。'); + throw new Error( + 'files 模式无需本地归档且不支持 --defer-upload;失败后使用同一 work-dir 重跑即可续传。', + ); } - const stopService = args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE); - const restartServicesAfter = collectRestartServicesAfterBackup({args, env}); + const stopService = + args.stopService || + firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE); + const restartServicesAfter = collectRestartServicesAfterBackup({ + args, + env, + }); + const stopMarkerPath = databaseBackupStopMarkerPath(workDir); let serviceStopped = false; let backupError = null; let restoreError = null; try { if (args.mode === 'full' && !args.dryRun) { - serviceStopped = stopServiceIfNeeded(stopService); + serviceStopped = stopServiceIfNeeded(stopService, stopMarkerPath); } await runDirectFilesBackup({ mode: args.mode, @@ -3318,7 +4259,13 @@ async function main() { objectPrefix, dryRun: args.dryRun, resultFile: args.resultFile, - uploadOptions: {bucket, endpoint, accessKeyId, accessKeySecret, bandwidthLimiter: uploadBandwidthLimiter}, + uploadOptions: { + bucket, + endpoint, + accessKeyId, + accessKeySecret, + bandwidthLimiter: uploadBandwidthLimiter, + }, concurrency: directFilesConcurrency, }); } catch (error) { @@ -3326,7 +4273,12 @@ async function main() { } finally { try { if (serviceStopped) { - restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter}); + restoreServicesAfterBackup({ + stopService, + serviceStopped, + restartServicesAfter, + stopMarkerPath, + }); } else if (!backupError && args.mode === 'full' && !args.dryRun) { restartServicesAfterBackup(restartServicesAfter); } @@ -3335,7 +4287,10 @@ async function main() { } } if (backupError && restoreError) { - throw new AggregateError([backupError, restoreError], `files 备份失败,且恢复依赖服务时也失败: ${backupError.message}; ${restoreError.message}`); + throw new AggregateError( + [backupError, restoreError], + `files 备份失败,且恢复依赖服务时也失败: ${backupError.message}; ${restoreError.message}`, + ); } if (backupError) { throw backupError; @@ -3364,7 +4319,11 @@ async function main() { return; } - const {fileName, objectKey} = buildBackupNames({database, dataDir, objectPrefix}); + const { fileName, objectKey } = buildBackupNames({ + database, + dataDir, + objectPrefix, + }); console.log(`[database-backup] 数据目录: ${dataDir}`); console.log(`[database-backup] 本地临时目录: ${workDir}`); console.log(`[database-backup] 目标对象: oss://${bucket}/${objectKey}`); @@ -3378,18 +4337,26 @@ async function main() { let serviceStopped = false; let backupError = null; let restoreError = null; - const stopService = args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE); - const restartServicesAfter = collectRestartServicesAfterBackup({args, env}); + const stopService = + args.stopService || + firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE); + const restartServicesAfter = collectRestartServicesAfterBackup({ args, env }); + const stopMarkerPath = databaseBackupStopMarkerPath(workDir); try { - assertSufficientWorkDirSpace({dataDir, workDir, args, env}); - serviceStopped = stopServiceIfNeeded(stopService); - archivePath = createArchive({dataDir, workDir, fileName}); + assertSufficientWorkDirSpace({ dataDir, workDir, args, env }); + serviceStopped = stopServiceIfNeeded(stopService, stopMarkerPath); + archivePath = createArchive({ dataDir, workDir, fileName }); } catch (error) { backupError = error; } finally { try { if (serviceStopped) { - restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter}); + restoreServicesAfterBackup({ + stopService, + serviceStopped, + restartServicesAfter, + stopMarkerPath, + }); } else if (!backupError) { restartServicesAfterBackup(restartServicesAfter); } @@ -3399,7 +4366,10 @@ async function main() { } if (backupError) { if (restoreError) { - throw new AggregateError([backupError, restoreError], `数据库备份失败,且恢复依赖服务时也失败: ${backupError.message}; ${restoreError.message}`); + throw new AggregateError( + [backupError, restoreError], + `数据库备份失败,且恢复依赖服务时也失败: ${backupError.message}; ${restoreError.message}`, + ); } throw backupError; } @@ -3408,7 +4378,7 @@ async function main() { } const manifestPath = `${archivePath}.manifest.json`; - const baselineStatePath = historyStatePath({args, env, workDir, database}); + const baselineStatePath = historyStatePath({ args, env, workDir, database }); const fullManifest = { backupKind: 'spacetimedb-data-dir', createdAt: new Date().toISOString(), @@ -3429,7 +4399,11 @@ async function main() { console.log(`[database-backup] 已生成本地冷备份,延后上传: ${archivePath}`); console.log(`[database-backup] 已写入备份清单: ${manifestPath}`); if (args.resultFile) { - writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({archivePath, manifestPath, baselineStatePath, bucket, objectKey}, null, 2)}\n`, 'utf8'); + writeFileSync( + resolvePath(args.resultFile), + `${JSON.stringify({ archivePath, manifestPath, baselineStatePath, bucket, objectKey }, null, 2)}\n`, + 'utf8', + ); } return; } @@ -3444,8 +4418,12 @@ async function main() { bandwidthLimiter: uploadBandwidthLimiter, }); console.log(`[database-backup] 上传完成: ${JSON.stringify(result)}`); - const uploadedManifest = uploadedManifestPayload({manifest: fullManifest, database, result}); - writeManifest({manifestPath, payload: uploadedManifest}); + const uploadedManifest = uploadedManifestPayload({ + manifest: fullManifest, + database, + result, + }); + writeManifest({ manifestPath, payload: uploadedManifest }); const manifestUpload = await uploadManifestFile({ manifestPath, bucket, @@ -3458,18 +4436,26 @@ async function main() { uploadedManifest.manifestVerifiedAt = manifestUpload.verifiedAt; uploadedManifest.manifestContentLength = manifestUpload.contentLength; uploadedManifest.manifestArchiveSha256 = manifestUpload.archiveSha256; - writeManifest({manifestPath, payload: uploadedManifest}); + writeManifest({ manifestPath, payload: uploadedManifest }); const previousState = existsSync(baselineStatePath) - ? validateHistoryState(readManifest(baselineStatePath), {database, dataDir}) + ? validateHistoryState(readManifest(baselineStatePath), { + database, + dataDir, + }) : null; - const baseline = normalizeUploadedBaselineManifest(uploadedManifest, {database, dataDir}); - writeBaselineState({statePath: baselineStatePath, baseline, previousState}); + const baseline = normalizeUploadedBaselineManifest(uploadedManifest, { + database, + dataDir, + }); + writeBaselineState({ statePath: baselineStatePath, baseline, previousState }); console.log(`[database-backup] 已写入 baseline state: ${baselineStatePath}`); if (!keepLocal) { - rmSync(archivePath, {force: true}); - rmSync(manifestPath, {force: true}); - console.log('[database-backup] 已删除本地临时备份文件;如需保留请设置 --keep-local。'); + rmSync(archivePath, { force: true }); + rmSync(manifestPath, { force: true }); + console.log( + '[database-backup] 已删除本地临时备份文件;如需保留请设置 --keep-local。', + ); } else { console.log(`[database-backup] 已保留本地备份: ${archivePath}`); console.log(`[database-backup] 已保留备份清单: ${manifestPath}`); @@ -3483,7 +4469,9 @@ function formatErrorDetails(error) { return ['code', 'errno', 'syscall', 'hostname', 'host', 'port', 'address'] .map((field) => { const value = error[field]; - return value === undefined || value === null || value === '' ? '' : `${field}=${String(value)}`; + return value === undefined || value === null || value === '' + ? '' + : `${field}=${String(value)}`; }) .filter(Boolean) .join(' '); @@ -3506,9 +4494,14 @@ function describeError(error) { } if (current instanceof AggregateError) { current.errors.slice(0, 3).forEach((item, index) => { - const itemText = item instanceof Error ? `${item.name}: ${item.message}` : String(item); + const itemText = + item instanceof Error + ? `${item.name}: ${item.message}` + : String(item); const itemDetails = formatErrorDetails(item); - lines.push(`${label}.errors[${index}]: ${itemText}${itemDetails ? ` (${itemDetails})` : ''}`); + lines.push( + `${label}.errors[${index}]: ${itemText}${itemDetails ? ` (${itemDetails})` : ''}`, + ); }); } current = current.cause; @@ -3516,7 +4509,10 @@ function describeError(error) { return lines; } -if (process.argv[1] && realpathSync(resolve(process.argv[1])) === realpathSync(__filename)) { +if ( + process.argv[1] && + realpathSync(resolve(process.argv[1])) === realpathSync(__filename) +) { main().catch((error) => { for (const line of describeError(error)) { console.error(`[database-backup] ${line}`); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 8e0d3bbed..4fccb149f 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -614,8 +614,8 @@ function parseSpacetimeToolCommit(output) { } function expectedSpacetimeToolCommit(workspaceVersion) { - if (workspaceVersion === '2.7.0') { - return 'd220349adb7af7eefa810eb08a185609356b83f6'; + if (workspaceVersion === '2.8.3') { + return '8e410d2842147bd8e5a32a9589cc00c19f7478e2'; } return ''; } @@ -623,7 +623,7 @@ function expectedSpacetimeToolCommit(workspaceVersion) { function buildSpacetimeUpgradeInstruction(workspaceVersion) { const expectedCommit = expectedSpacetimeToolCommit(workspaceVersion); if (expectedCommit) { - return `请安装官方 v${workspaceVersion}-hotfix3 发行包并确认 spacetime --version 的 Commit 为 ${expectedCommit},然后重新运行本命令。`; + return `请安装官方 v${workspaceVersion} 发行包并确认 spacetime --version 的 Commit 为 ${expectedCommit},然后重新运行本命令。`; } return `请先直接升级并切换到锁定版本: spacetime version install ${workspaceVersion} && spacetime version use ${workspaceVersion},然后重新运行本命令。`; } @@ -667,7 +667,7 @@ function assertSpacetimeToolVersionMatchesWorkspace({ throw new Error( [ `本机 spacetime CLI/standalone 虽报告 ${toolVersion},但构建 commit ${toolCommit || 'unknown'} 不是项目锁定的 ${expectedCommit}。`, - '同版本号的裸 tag 缺少后续 hotfix,不能用于本地 SpacetimeDB。', + '同版本号但 commit 不匹配的构建存在发布资产差异风险,不能用于本地 SpacetimeDB。', buildSpacetimeUpgradeInstruction(workspaceVersion), ].join(''), ); diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index 4e8778888..a8883b337 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -376,7 +376,9 @@ describe('dev scheduler api-server env', () => { expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_HOST).toBe('127.0.0.1'); expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_PORT).toBe('18083'); expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_CONCURRENCY).toBe('16'); - expect(workerEnv.GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS).toBe('5000'); + expect(workerEnv.GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS).toBe( + '5000', + ); expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS).toBe('2048'); }); @@ -773,7 +775,7 @@ describe('dev scheduler spacetime reuse guard', () => { writeFileSync(join(tempDir, 'spacetime.pid'), `${process.pid}\n`, 'utf8'); writeFileSync( join(tempDir, 'dev-spacetime-tool-version'), - `${workspaceSpacetimeVersionForTest()}\nd220349adb7af7eefa810eb08a185609356b83f6\n`, + `${workspaceSpacetimeVersionForTest()}\n8e410d2842147bd8e5a32a9589cc00c19f7478e2\n`, 'utf8', ); globalThis.fetch = vi.fn(async () => ({ @@ -802,7 +804,7 @@ describe('dev scheduler spacetime reuse guard', () => { writeFileSync(join(tempDir, 'spacetime.pid'), `${process.pid}\n`, 'utf8'); writeFileSync( join(tempDir, 'dev-spacetime-tool-version'), - `${workspaceSpacetimeVersionForTest()}\nd220349adb7af7eefa810eb08a185609356b83f6\n`, + `${workspaceSpacetimeVersionForTest()}\n8e410d2842147bd8e5a32a9589cc00c19f7478e2\n`, 'utf8', ); globalThis.fetch = vi.fn(async () => ({ @@ -915,30 +917,30 @@ describe('dev scheduler spacetime refresh', () => { test('解析 spacetime --version 输出里的 tool version', () => { const output = ` A new version of SpacetimeDB is available: v2.6.1 (current: v2.5.0) -Commit: d220349adb7af7eefa810eb08a185609356b83f6 -spacetimedb tool version 2.7.0; spacetimedb-lib version 2.7.0; +Commit: 8e410d2842147bd8e5a32a9589cc00c19f7478e2 +spacetimedb tool version 2.8.3; spacetimedb-lib version 2.8.3; `; - expect(parseSpacetimeToolVersion(output)).toBe('2.7.0'); + expect(parseSpacetimeToolVersion(output)).toBe('2.8.3'); expect(parseSpacetimeToolCommit(output)).toBe( - 'd220349adb7af7eefa810eb08a185609356b83f6', + '8e410d2842147bd8e5a32a9589cc00c19f7478e2', ); }); - test('同为 2.7.0 时拒绝裸 tag 并接受 hotfix3 commit', () => { + test('同为 2.8.3 时拒绝 commit 不匹配并接受锁定 commit', () => { expect(() => assertSpacetimeToolVersionMatchesWorkspace({ - toolVersion: '2.7.0', - toolCommit: 'a08663c7b94688a2542577532d472f751e641f5b', - workspaceVersion: '2.7.0', + toolVersion: '2.8.3', + toolCommit: '0000000000000000000000000000000000000000', + workspaceVersion: '2.8.3', }), - ).toThrow('裸 tag'); + ).toThrow('构建 commit'); expect(() => assertSpacetimeToolVersionMatchesWorkspace({ - toolVersion: '2.7.0', - toolCommit: 'd220349adb7af7eefa810eb08a185609356b83f6', - workspaceVersion: '2.7.0', + toolVersion: '2.8.3', + toolCommit: '8e410d2842147bd8e5a32a9589cc00c19f7478e2', + workspaceVersion: '2.8.3', }), ).not.toThrow(); }); @@ -974,14 +976,14 @@ spacetimedb tool version 2.7.0; spacetimedb-lib version 2.7.0; } }); - test('复用本地 SpacetimeDB standalone 时拒绝同版本裸 tag 记录', () => { + test('复用本地 SpacetimeDB standalone 时拒绝同版本但 commit 不匹配的记录', () => { const tempDir = mkdtempSync( join(tmpdir(), 'genarrative-spacetime-commit-'), ); try { writeFileSync( join(tempDir, 'dev-spacetime-tool-version'), - '2.7.0\na08663c7b94688a2542577532d472f751e641f5b\n', + '2.8.3\n0000000000000000000000000000000000000000\n', 'utf8', ); @@ -990,7 +992,7 @@ spacetimedb tool version 2.7.0; spacetimedb-lib version 2.7.0; dataDir: tempDir, serverUrl: 'http://127.0.0.1:3101', }), - ).toThrow('裸 tag'); + ).toThrow('构建 commit'); } finally { rmSync(tempDir, { recursive: true, force: true }); } diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh index 894458fd2..259b6f6c8 100755 --- a/scripts/jenkins-server-provision.sh +++ b/scripts/jenkins-server-provision.sh @@ -78,6 +78,10 @@ validate_database_backup_profile() { exit 1 ;; esac + if [[ "${DEPLOY_TARGET}" == "release" && "${DATABASE_BACKUP_PROFILE}" == "files-history" ]]; then + echo "[server-provision] release 仅允许 archive-full;files-history 会把整棵历史目录加载到 Node 内存,需先完成流式 catalog 改造后才能重新启用。" >&2 + exit 1 + fi if [[ ! "${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}" =~ ^/var/lib/genarrative/database-backups/[A-Za-z0-9._/-]+$ || "${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}" == *..* ]]; then echo "[server-provision] DATABASE_BACKUP_FILES_HISTORY_WORK_DIR 必须是 /var/lib/genarrative/database-backups/ 下不含连续点号的绝对路径,当前值: ${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}" >&2 exit 1 diff --git a/scripts/prepare-server-provision-tools.sh b/scripts/prepare-server-provision-tools.sh index fcef281ac..724f0f088 100755 --- a/scripts/prepare-server-provision-tools.sh +++ b/scripts/prepare-server-provision-tools.sh @@ -9,11 +9,11 @@ OTELCOL_DOWNLOAD_ROOT="${OTELCOL_DOWNLOAD_ROOT:-https://github.com/open-telemetr OTELCOL_ARCHIVE_PATH="${OTELCOL_ARCHIVE_PATH:-}" OTELCOL_TARGET_BIN="${OTELCOL_TARGET_BIN:-/usr/local/bin/otelcol-contrib}" SPACETIME_INSTALLER_URL="${SPACETIME_INSTALLER_URL:-https://install.spacetimedb.com}" -SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.7.0-hotfix3}" +SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.8.3}" SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" SPACETIME_ROOT="${SPACETIME_ROOT:-/stdb}" SPACETIME_EXPECTED_VERSION="${SPACETIME_EXPECTED_VERSION:-}" -SPACETIME_EXPECTED_COMMIT="${SPACETIME_EXPECTED_COMMIT:-d220349adb7af7eefa810eb08a185609356b83f6}" +SPACETIME_EXPECTED_COMMIT="${SPACETIME_EXPECTED_COMMIT:-8e410d2842147bd8e5a32a9589cc00c19f7478e2}" SPACETIME_ARCHIVE_PATH="${SPACETIME_ARCHIVE_PATH:-}" SPACETIME_INSTALLER_PATH="${SPACETIME_INSTALLER_PATH:-}" SPACETIME_UPDATE_INSTALLER_PATH="${SPACETIME_UPDATE_INSTALLER_PATH:-}" diff --git a/scripts/rag/README.md b/scripts/rag/README.md index cbdfeea6d..725abb34f 100644 --- a/scripts/rag/README.md +++ b/scripts/rag/README.md @@ -80,4 +80,4 @@ Agent 使用规则: - `docs/project-memory/` - `docs/` -`.hermes/` 是 Hermes 工具目录,不作为项目知识库索引源。 +`.codex/` 是 Codex 工具目录,不作为项目知识库索引源。 diff --git a/scripts/rag/rag-config.json b/scripts/rag/rag-config.json index 0d33855d7..18df13270 100644 --- a/scripts/rag/rag-config.json +++ b/scripts/rag/rag-config.json @@ -30,7 +30,7 @@ "exclude": [ ".git/", ".rag/", - ".hermes/", + ".codex/", ".codegraph/", ".app/", "node_modules/", diff --git a/scripts/spacetime-editor-idempotency-smoke.mjs b/scripts/spacetime-editor-idempotency-smoke.mjs index e34c7ba8d..3b79c0836 100644 --- a/scripts/spacetime-editor-idempotency-smoke.mjs +++ b/scripts/spacetime-editor-idempotency-smoke.mjs @@ -16,8 +16,8 @@ const repoRoot = path.resolve( '..', ); const database = 'editor-idempotency-smoke'; -const expectedSpacetimeVersion = '2.7.0'; -const expectedSpacetimeCommit = 'd220349adb7af7eefa810eb08a185609356b83f6'; +const expectedSpacetimeVersion = '2.8.3'; +const expectedSpacetimeCommit = '8e410d2842147bd8e5a32a9589cc00c19f7478e2'; const commandTimeoutMs = 5 * 60 * 1000; function assert(condition, message) { @@ -228,6 +228,10 @@ export async function stopStandalone(child) { if (child.exitCode !== null) { return; } + if (process.platform === 'win32') { + await stopWindowsProcessTree(child); + return; + } child.kill('SIGTERM'); const exited = await Promise.race([ once(child, 'exit').then(() => true), @@ -239,6 +243,37 @@ export async function stopStandalone(child) { } } +async function stopWindowsProcessTree(child) { + if (typeof child.pid === 'number') { + await runTaskKill(child.pid); + } + const exited = await Promise.race([ + once(child, 'exit').then(() => true), + delay(5_000).then(() => false), + ]); + if (!exited && child.exitCode === null) { + child.kill('SIGKILL'); + await once(child, 'exit'); + } +} + +function runTaskKill(pid) { + return new Promise((resolve, reject) => { + const taskKill = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { + stdio: 'ignore', + shell: false, + }); + taskKill.once('error', reject); + taskKill.once('exit', (code, signal) => { + if (code === 0 || code === 128 || code === 1) { + resolve(); + return; + } + reject(new Error(`taskkill exited with ${signal ?? code}`)); + }); + }); +} + function cliPrefix(configPath) { return ['--config-path', configPath]; } diff --git a/scripts/spacetime-maintain-external-generation-jobs.mjs b/scripts/spacetime-maintain-external-generation-jobs.mjs index 69ead2f3c..49a6c8625 100644 --- a/scripts/spacetime-maintain-external-generation-jobs.mjs +++ b/scripts/spacetime-maintain-external-generation-jobs.mjs @@ -8,23 +8,29 @@ import { } from './spacetime-migration-common.mjs'; const MAX_BATCH_SIZE = 25; +const DEFAULT_RETENTION_DAYS = 30; +const MICROS_PER_DAY = 86_400_000_000; function usage() { return `用法: node scripts/spacetime-maintain-external-generation-jobs.mjs --database [选项] 默认只 dry-run 一批历史终态任务 payload 压缩,不修改数据库。 +使用 --prune-history 时改为清理已确认通知且超过保留期的历史任务、摘要与事件。 公共选项: --database 目标数据库(必填,也可用 GENARRATIVE_SPACETIME_DATABASE) --server spacetime CLI server 名或 URL --server-url 显式 server URL - --limit <1-${MAX_BATCH_SIZE}> 单批任务数,默认 10 + --limit <1-${MAX_BATCH_SIZE}> 单批任务数,默认 10 --cursor-job-id 从上一批 next_cursor_job_id 继续 --apply 执行写入;省略时始终 dry-run --backfill-summaries 改为回填轻量摘要投影 + --prune-history 改为清理已确认通知的终态历史 --owner-user-id 仅摘要回填可选,限定 owner - --completed-before-micros 仅 payload 压缩可选,限定终态完成时间 + --source-module 仅历史清理可选,默认 editor-canvas + --retention-days 仅历史清理可选,默认 ${DEFAULT_RETENTION_DAYS} 天 + --completed-before-micros 限定终态完成时间;历史清理默认按 retention-days 计算 --help 显示帮助 必须使用已授权 migration operator 的 spacetime CLI 登录态。脚本每次只处理一批; @@ -40,6 +46,9 @@ function parseOptions(argv) { database: process.env.GENARRATIVE_SPACETIME_DATABASE || '', limit: 10, ownerUserId: '', + pruneHistory: false, + retentionDays: DEFAULT_RETENTION_DAYS, + sourceModule: 'editor-canvas', passthrough: [], server: process.env.GENARRATIVE_SPACETIME_SERVER || '', serverUrl: process.env.GENARRATIVE_SPACETIME_SERVER_URL || '', @@ -82,6 +91,15 @@ function parseOptions(argv) { options.apply = true; } else if (arg === '--backfill-summaries') { options.backfillSummaries = true; + } else if (arg === '--prune-history') { + options.pruneHistory = true; + } else if (arg === '--source-module') { + options.sourceModule = readValue(arg).trim(); + if (!options.sourceModule) { + throw new Error('--source-module 不能为空。'); + } + } else if (arg === '--retention-days') { + options.retentionDays = parsePositiveInteger(readValue(arg), arg); } else if (arg === '--help' || arg === '-h') { options.help = true; } else { @@ -95,12 +113,49 @@ function parseOptions(argv) { if (options.ownerUserId && !options.backfillSummaries) { throw new Error('--owner-user-id 只能与 --backfill-summaries 一起使用。'); } + if (options.backfillSummaries && options.pruneHistory) { + throw new Error('--backfill-summaries 与 --prune-history 不能同时使用。'); + } + if (options.sourceModule !== 'editor-canvas' && !options.pruneHistory) { + throw new Error('--source-module 只能与 --prune-history 一起使用。'); + } + if ( + options.retentionDays !== DEFAULT_RETENTION_DAYS && + !options.pruneHistory + ) { + throw new Error('--retention-days 只能与 --prune-history 一起使用。'); + } if (options.completedBeforeMicros !== null && options.backfillSummaries) { throw new Error('--completed-before-micros 不能用于摘要回填。'); } + if ( + options.completedBeforeMicros !== null && + options.pruneHistory && + options.retentionDays !== DEFAULT_RETENTION_DAYS + ) { + throw new Error( + '--completed-before-micros 与 --retention-days 不能同时使用。', + ); + } return options; } +function resolveRetentionCutoffMicros(options) { + if (!options.pruneHistory) { + return options.completedBeforeMicros; + } + if (options.completedBeforeMicros !== null) { + return options.completedBeforeMicros; + } + const cutoff = Date.now() * 1000 - options.retentionDays * MICROS_PER_DAY; + if (!Number.isSafeInteger(cutoff)) { + throw new Error( + '--retention-days 计算出的 completed_before_micros 超出安全整数范围。', + ); + } + return cutoff; +} + try { const options = parseOptions(process.argv.slice(2)); if (options.help) { @@ -113,24 +168,36 @@ try { ); } - const procedureName = options.backfillSummaries - ? 'backfill_external_generation_job_summaries_and_return' - : 'compact_external_generation_job_payloads_and_return'; - const input = options.backfillSummaries + const completedBeforeMicros = resolveRetentionCutoffMicros(options); + + const procedureName = options.pruneHistory + ? 'prune_external_generation_job_history_and_return' + : options.backfillSummaries + ? 'backfill_external_generation_job_summaries_and_return' + : 'compact_external_generation_job_payloads_and_return'; + const input = options.pruneHistory ? { - owner_user_id: encodeSpacetimeCliOption(options.ownerUserId || null), + source_module: options.sourceModule, limit: options.limit, cursor_job_id: encodeSpacetimeCliOption(options.cursorJobId || null), + completed_before_micros: completedBeforeMicros, dry_run: !options.apply, } - : { - dry_run: !options.apply, - limit: options.limit, - cursor_job_id: encodeSpacetimeCliOption(options.cursorJobId || null), - completed_before_micros: encodeSpacetimeCliOption( - options.completedBeforeMicros, - ), - }; + : options.backfillSummaries + ? { + owner_user_id: encodeSpacetimeCliOption(options.ownerUserId || null), + limit: options.limit, + cursor_job_id: encodeSpacetimeCliOption(options.cursorJobId || null), + dry_run: !options.apply, + } + : { + dry_run: !options.apply, + limit: options.limit, + cursor_job_id: encodeSpacetimeCliOption(options.cursorJobId || null), + completed_before_micros: encodeSpacetimeCliOption( + completedBeforeMicros, + ), + }; const result = await callSpacetimeProcedureViaCli( options, procedureName, @@ -138,10 +205,29 @@ try { ); ensureProcedureOk(result); - console.log(JSON.stringify({ procedure: procedureName, ...result }, null, 2)); - const pendingApplyCount = options.backfillSummaries - ? Number(result.selected_count ?? 0) - : Number(result.matched_count ?? 0); + console.log( + JSON.stringify( + { + procedure: procedureName, + ...(options.pruneHistory + ? { + source_module: options.sourceModule, + completed_before_micros: completedBeforeMicros, + ...(options.completedBeforeMicros === null + ? { retention_days: options.retentionDays } + : {}), + } + : {}), + ...result, + }, + null, + 2, + ), + ); + const pendingApplyCount = + options.pruneHistory || options.backfillSummaries + ? Number(result.selected_count ?? 0) + : Number(result.matched_count ?? 0); if (result.has_more && options.apply) { console.log( `仍有后续批次;下一次追加 --cursor-job-id ${result.next_cursor_job_id ?? ''}。`, @@ -150,8 +236,11 @@ try { const currentCursor = options.cursorJobId ? `保留 --cursor-job-id ${options.cursorJobId}` : '仍从首批开始'; + const cutoffHint = options.pruneHistory + ? `并固定 --completed-before-micros ${completedBeforeMicros}` + : ''; console.log( - `当前仅 dry-run;请${currentCursor}并追加 --apply 重跑同一批。apply 成功后再使用其 next_cursor_job_id 进入下一批。`, + `当前仅 dry-run;请${currentCursor}${cutoffHint}并追加 --apply 重跑同一批。apply 成功后再使用其 next_cursor_job_id 进入下一批。`, ); } } catch (error) { diff --git a/scripts/spacetime-migration-common.mjs b/scripts/spacetime-migration-common.mjs index 6cc7cbbe4..1f2341ad4 100644 --- a/scripts/spacetime-migration-common.mjs +++ b/scripts/spacetime-migration-common.mjs @@ -9,11 +9,13 @@ export function parseArgs(argv) { 'GENARRATIVE_SPACETIME_MIGRATION_CHUNK_SIZE', ), database: process.env.GENARRATIVE_SPACETIME_DATABASE || '', - bootstrapSecret: process.env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET || '', + bootstrapSecret: + process.env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET || '', bootstrapSecretFile: process.env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_FILE || '', includeTables: [], - operatorIdentity: process.env.GENARRATIVE_SPACETIME_MIGRATION_OPERATOR_IDENTITY || '', + operatorIdentity: + process.env.GENARRATIVE_SPACETIME_MIGRATION_OPERATOR_IDENTITY || '', passthrough: [], note: '', server: process.env.GENARRATIVE_SPACETIME_SERVER || '', @@ -142,7 +144,9 @@ export function buildSpacetimeCallArgs(options, procedureName, input) { export async function callSpacetimeProcedure(options, procedureName, input) { if (!options.database) { - throw new Error('必须传入 --database,或设置 GENARRATIVE_SPACETIME_DATABASE。'); + throw new Error( + '必须传入 --database,或设置 GENARRATIVE_SPACETIME_DATABASE。', + ); } validateSpacetimeDatabaseName(options.database); @@ -196,7 +200,9 @@ export async function createSpacetimeWebIdentity(options) { const text = await response.text(); if (!response.ok) { - throw new Error(`SpacetimeDB identity HTTP ${response.status}: ${trimPreview(text)}`); + throw new Error( + `SpacetimeDB identity HTTP ${response.status}: ${trimPreview(text)}`, + ); } let payload; @@ -209,16 +215,25 @@ export async function createSpacetimeWebIdentity(options) { } const identity = - payload.identity ?? payload.Identity ?? payload.identity_hex ?? payload.identityHex; + payload.identity ?? + payload.Identity ?? + payload.identity_hex ?? + payload.identityHex; const token = payload.token ?? payload.Token; if (typeof identity !== 'string' || typeof token !== 'string') { - throw new Error(`SpacetimeDB identity 响应缺少 identity/token: ${trimPreview(text)}`); + throw new Error( + `SpacetimeDB identity 响应缺少 identity/token: ${trimPreview(text)}`, + ); } return { identity, token }; } -export async function callSpacetimeProcedureAuto(options, procedureName, input) { +export async function callSpacetimeProcedureAuto( + options, + procedureName, + input, +) { if (options.useHttp) { return callSpacetimeProcedure(options, procedureName, input); } @@ -226,7 +241,11 @@ export async function callSpacetimeProcedureAuto(options, procedureName, input) return callSpacetimeProcedureViaCli(options, procedureName, input); } -export async function callSpacetimeProcedureViaCli(options, procedureName, input) { +export async function callSpacetimeProcedureViaCli( + options, + procedureName, + input, +) { const args = buildSpacetimeCallArgs(options, procedureName, input); const output = await runSpacetimeCli(args); return parseProcedureResult(output, procedureName); @@ -335,7 +354,8 @@ function normalizeSatsProduct(value, procedureName) { } if ( - procedureName === 'normalize_editor_character_animation_metadata_and_return' && + procedureName === + 'normalize_editor_character_animation_metadata_and_return' && value.length === 19 ) { return { @@ -427,6 +447,24 @@ function normalizeSatsProduct(value, procedureName) { }; } + if ( + procedureName === 'prune_external_generation_job_history_and_return' && + value.length === 10 + ) { + return { + ok: normalizeSatsValue(value[0]), + dry_run: normalizeSatsValue(value[1]), + scanned_count: normalizeSatsValue(value[2]), + selected_count: normalizeSatsValue(value[3]), + deleted_job_count: normalizeSatsValue(value[4]), + deleted_summary_count: normalizeSatsValue(value[5]), + deleted_event_count: normalizeSatsValue(value[6]), + next_cursor_job_id: normalizeSatsOption(value[7]), + has_more: normalizeSatsValue(value[8]), + error_message: normalizeSatsOption(value[9]), + }; + } + if (value.length === 3) { return { ok: normalizeSatsValue(value[0]), @@ -497,7 +535,10 @@ function normalizeSatsValue(value) { if (value && typeof value === 'object') { return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, normalizeSatsValue(entry)]), + Object.entries(value).map(([key, entry]) => [ + key, + normalizeSatsValue(entry), + ]), ); } @@ -581,7 +622,9 @@ export function resolveServerUrl(options) { return 'http://127.0.0.1:3101'; } - throw new Error(`未知 SpacetimeDB server: ${server}。请改用 --server-url 显式传入地址。`); + throw new Error( + `未知 SpacetimeDB server: ${server}。请改用 --server-url 显式传入地址。`, + ); } function resolveCliServer(options) { @@ -635,7 +678,11 @@ function runSpacetimeCli(args) { return; } if (code !== 0) { - reject(new Error(`spacetime call 失败,退出码 ${code}: ${trimPreview(output)}`)); + reject( + new Error( + `spacetime call 失败,退出码 ${code}: ${trimPreview(output)}`, + ), + ); return; } diff --git a/scripts/spacetime-migration-common.test.ts b/scripts/spacetime-migration-common.test.ts index 6317bd5d9..689abe321 100644 --- a/scripts/spacetime-migration-common.test.ts +++ b/scripts/spacetime-migration-common.test.ts @@ -223,4 +223,35 @@ describe('SpacetimeDB CLI SATS option encoding', () => { expect(objectResult.batch_sha256).toBe('d'.repeat(64)); expect(objectResult).not.toHaveProperty('batch_sha_256'); }); + + it('normalizes external generation history prune tuple results', () => { + const result = parseProcedureResult( + JSON.stringify([ + true, + false, + 25, + 2, + 2, + 2, + 10, + [0, 'job-25'], + true, + [0, '清理失败'], + ]), + 'prune_external_generation_job_history_and_return', + ); + + expect(result).toEqual({ + ok: true, + dry_run: false, + scanned_count: 25, + selected_count: 2, + deleted_job_count: 2, + deleted_summary_count: 2, + deleted_event_count: 10, + next_cursor_job_id: 'job-25', + has_more: true, + error_message: '清理失败', + }); + }); }); diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 3689d1b5b..7d79543c0 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -52,6 +52,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.12" @@ -171,7 +182,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -182,7 +193,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -204,7 +215,7 @@ dependencies = [ "aes", "async-stream", "axum", - "base64 0.22.1", + "base64", "bytes", "cbc", "dotenvy", @@ -445,7 +456,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", - "base64 0.22.1", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -508,12 +519,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -953,17 +958,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "console" -version = "0.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" -dependencies = [ - "encode_unicode", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "constant_time_eq" version = "0.4.2" @@ -976,15 +970,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" -[[package]] -name = "convert_case" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -1145,7 +1130,7 @@ dependencies = [ "openssl-sys", "schannel", "socket2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1160,7 +1145,7 @@ dependencies = [ "openssl-sys", "pkg-config", "vcpkg", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1184,7 +1169,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70def8d72740e44d9f676d8dab2c933a236663d86dd24319b57a2bed4d694774" dependencies = [ - "petgraph 0.7.1", + "petgraph", ] [[package]] @@ -1354,7 +1339,7 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case 0.4.0", + "convert_case", "proc-macro2", "quote", "rustc_version", @@ -1463,7 +1448,7 @@ version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6" dependencies = [ - "ahash", + "ahash 0.8.12", "bytemuck", "document-features", "egui", @@ -1498,7 +1483,7 @@ version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" dependencies = [ - "ahash", + "ahash 0.8.12", "bitflags 2.13.0", "emath", "epaint", @@ -1515,7 +1500,7 @@ version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236" dependencies = [ - "ahash", + "ahash 0.8.12", "bytemuck", "document-features", "egui", @@ -1582,12 +1567,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "encoding_rs" version = "0.8.35" @@ -1609,26 +1588,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "enum-map" -version = "2.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" -dependencies = [ - "enum-map-derive", -] - -[[package]] -name = "enum-map-derive" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "epaint" version = "0.33.3" @@ -1636,7 +1595,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" dependencies = [ "ab_glyph", - "ahash", + "ahash 0.8.12", "bytemuck", "ecolor", "emath", @@ -1666,7 +1625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1711,12 +1670,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "fixedbitset" version = "0.5.7" @@ -2106,6 +2059,9 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] [[package]] name = "hashbrown" @@ -2135,11 +2091,6 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] [[package]] name = "heck" @@ -2301,7 +2252,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", @@ -2517,23 +2468,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "insta" -version = "1.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" -dependencies = [ - "console", - "once_cell", - "regex", - "serde", - "similar", - "strip-ansi-escapes", - "tempfile", - "toml_edit", - "toml_writer", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -2655,7 +2589,7 @@ version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ - "base64 0.22.1", + "base64", "js-sys", "pem", "ring", @@ -3130,7 +3064,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3719,7 +3653,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64 0.22.1", + "base64", "serde_core", ] @@ -3729,23 +3663,13 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "petgraph" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" -dependencies = [ - "fixedbitset 0.4.2", - "indexmap 2.14.0", -] - [[package]] name = "petgraph" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ - "fixedbitset 0.5.7", + "fixedbitset", "indexmap 2.14.0", ] @@ -3795,7 +3719,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527735ac204efb9fa3884bfd9224d016c5735fabe1d394ebed145b40e7545b99" dependencies = [ - "ahash", + "ahash 0.8.12", "async-trait", "blake2", "bstr", @@ -3822,7 +3746,7 @@ dependencies = [ "rmp", "rmp-serde", "serde", - "strum 0.26.3", + "strum", "tokio", ] @@ -3832,7 +3756,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a7ffe2f5acf9f94fd255cfd1438866bc9124f8f0c7d42562bd3f853df2094b7" dependencies = [ - "ahash", + "ahash 0.8.12", "async-trait", "brotli", "bstr", @@ -3869,8 +3793,8 @@ dependencies = [ "serde_yaml", "sfv", "socket2", - "strum 0.26.3", - "strum_macros 0.26.4", + "strum", + "strum_macros", "tokio", "tokio-stream", "tokio-test", @@ -3970,7 +3894,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6705a26ad89d241a989a5395641931ba37076f5ab5fbd19ee92402414a43af32" dependencies = [ "arrayvec", - "hashbrown 0.17.1", + "hashbrown 0.12.3", "parking_lot", "rand 0.8.6", ] @@ -4080,7 +4004,7 @@ dependencies = [ name = "platform-audio" version = "0.1.0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "platform-oss", "regex", @@ -4133,7 +4057,7 @@ dependencies = [ name = "platform-hyper3d" version = "0.1.0" dependencies = [ - "base64 0.22.1", + "base64", "reqwest", "serde_json", "shared-contracts", @@ -4144,7 +4068,7 @@ dependencies = [ name = "platform-image" version = "0.1.0" dependencies = [ - "base64 0.22.1", + "base64", "curl", "image", "platform-oss", @@ -4190,7 +4114,7 @@ dependencies = [ name = "platform-oss" version = "0.1.0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "hmac", "reqwest", @@ -4206,7 +4130,7 @@ dependencies = [ name = "platform-speech" version = "0.1.0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "flate2", "futures-util", @@ -4223,7 +4147,7 @@ name = "platform-wechat" version = "0.1.0" dependencies = [ "aes", - "base64 0.22.1", + "base64", "cbc", "csv", "flate2", @@ -4741,7 +4665,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-core", @@ -4910,7 +4834,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5178,15 +5102,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -5205,7 +5120,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ - "base64 0.22.1", + "base64", "bs58", "chrono", "hex", @@ -5257,7 +5172,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fa1f336066b758b7c9df34ed049c0e693a426afe2b27ff7d5b14f410ab1a132" dependencies = [ - "base64 0.22.1", + "base64", "indexmap 2.14.0", "rust_decimal", ] @@ -5371,12 +5286,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - [[package]] name = "simple_asn1" version = "0.6.4" @@ -5489,7 +5398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5526,23 +5435,22 @@ dependencies = [ "shared-contracts", "spacetimedb", "spacetimedb-lib", + "time", ] [[package]] name = "spacetimedb" -version = "2.7.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ba4d29856a75aafc3c66fd47d39e11e20c5001ee323d90549ffe19980ef9ff" +checksum = "a968affe29adb73caf178220042de9b032c10af96ef6e12d90ad0782c5741342" dependencies = [ "anyhow", "bytemuck", "bytes", - "derive_more", "getrandom 0.2.17", "http", "log", "rand 0.8.6", - "scoped-tls", "serde_json", "spacetimedb-bindings-macro", "spacetimedb-bindings-sys", @@ -5553,36 +5461,34 @@ dependencies = [ [[package]] name = "spacetimedb-bindings-macro" -version = "2.7.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9c2548accd66ba0444caf12916e60ca282054fe5701298f633aa6a2a90c38b" +checksum = "ba5991f25d09022a8e60b5207c6c12d0067e360f4b35fc773b6ce79f9e4cbbf9" dependencies = [ "heck 0.4.1", "humantime", "proc-macro2", "quote", - "spacetimedb-primitives", "syn 2.0.118", ] [[package]] name = "spacetimedb-bindings-sys" -version = "2.7.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc1790a7705a2d83ee601c9b14e4fd543ee8fe777d5b6117d4c5d33fc6566d7" +checksum = "2202a202c6102f61bf14d819db1ca99d0e93ddb59b2832f880c07026010b5210" dependencies = [ "spacetimedb-primitives", ] [[package]] name = "spacetimedb-client-api-messages" -version = "2.7.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2ef1840b2157a9487618ad360a078d7b5d86de5eb27494efa5bbbb2ca76b247" +checksum = "d57c7ab845d782268cb92b8693c021c810b8cd625ca618fd6c83764a869b8c4b" dependencies = [ "bytes", "bytestring", - "chrono", "derive_more", "enum-as-inner", "serde", @@ -5592,17 +5498,16 @@ dependencies = [ "spacetimedb-lib", "spacetimedb-primitives", "spacetimedb-sats", - "strum 0.25.0", "thiserror 1.0.69", ] [[package]] name = "spacetimedb-data-structures" -version = "2.7.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20df758e7067fb085e9393786b3b12289838eac9fd78601cb1aaf7e059904ccb" +checksum = "7c4e9d07e21ad665c45736871e5005d8457ab3d2cdd7806e351aa5e1c8c2ac32" dependencies = [ - "ahash", + "ahash 0.8.12", "crossbeam-queue", "either", "hashbrown 0.16.1", @@ -5613,44 +5518,26 @@ dependencies = [ [[package]] name = "spacetimedb-lib" -version = "2.7.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a6b0417d7db37d991fd927a21298e09ad61cd5dc298eb9e1ac2433fe4a958a7" +checksum = "aa8ff238e1d5071ec5e422f35a5b9550f3997f5ff111e8470ca018c5b3cee393" dependencies = [ "anyhow", - "bitflags 2.13.0", "blake3", - "bytes", - "chrono", "derive_more", - "enum-as-inner", - "enum-map", "hex", - "itertools 0.12.1", - "log", "serde", "spacetimedb-bindings-macro", "spacetimedb-metrics", "spacetimedb-primitives", "spacetimedb-sats", - "thiserror 1.0.69", -] - -[[package]] -name = "spacetimedb-memory-usage" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "892d4ef38414498d687d765c4b0a9f6390f786c6fc5ca640a09c42f5da902f99" -dependencies = [ - "decorum", - "ethnum", ] [[package]] name = "spacetimedb-metrics" -version = "2.7.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de1ef3c1d55ec819953211e5ae645f1152e2b894ea770d59b039a7133b2ebf5f" +checksum = "51dd567d0a08c106615d337203a11107b6203cffc006118b081793dda8ebec64" dependencies = [ "arrayvec", "itertools 0.12.1", @@ -5660,36 +5547,34 @@ dependencies = [ [[package]] name = "spacetimedb-primitives" -version = "2.7.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2adee8bd225009feb67ab329aa4eae6b7fe6c4c0165d9d65814c07a4252b2d20" +checksum = "4e215ecb302533bfd9b70f082548afe75009d5014f1ecd645cc1c348d64e6b3c" dependencies = [ "bitflags 2.13.0", "either", "enum-as-inner", "itertools 0.12.1", "nohash-hasher", - "spacetimedb-memory-usage", ] [[package]] name = "spacetimedb-query-builder" -version = "2.7.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d8476efc6c3c1d9def4499bbeedcc015d5f13c75565dd7136180265dd45708c" +checksum = "41a57c9404ec80dacfde5d255eb6ebcfb2ba1e90b196a5a300eda65ed129d36f" dependencies = [ "spacetimedb-lib", ] [[package]] name = "spacetimedb-sats" -version = "2.7.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cfd92de752a8fd711ad2138ae79a80313be34061da6112fd46df1dd6365200" +checksum = "1bd4f9bb2ed4a9610ab55015d49371b4c4bda27099254f51832e82eff145164e" dependencies = [ "anyhow", "arrayvec", - "bitflags 2.13.0", "bytemuck", "bytes", "bytestring", @@ -5707,52 +5592,19 @@ dependencies = [ "sha3", "smallvec", "spacetimedb-bindings-macro", - "spacetimedb-memory-usage", "spacetimedb-metrics", "spacetimedb-primitives", "thiserror 1.0.69", "uuid", ] -[[package]] -name = "spacetimedb-schema" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "444649a34ca14d1535d03a375deacb8ad4668f692576bff7091b6d4160b1d346" -dependencies = [ - "anyhow", - "convert_case 0.6.0", - "derive_more", - "enum-as-inner", - "enum-map", - "indexmap 2.14.0", - "insta", - "itertools 0.12.1", - "lazy_static", - "lean_string", - "petgraph 0.6.5", - "serde_json", - "smallvec", - "spacetimedb-data-structures", - "spacetimedb-lib", - "spacetimedb-memory-usage", - "spacetimedb-primitives", - "spacetimedb-sats", - "spacetimedb-sql-parser", - "termcolor", - "thiserror 1.0.69", - "unicode-ident", - "unicode-normalization", -] - [[package]] name = "spacetimedb-sdk" -version = "2.7.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed0e49e45ab4828e9bf51d1c93db553f496c2836f1b49174f4019dc8c42e6525" +checksum = "b599dc08ab7d03f62481199fd7e7aaef67f145b36057057580aa041dbd01dfff" dependencies = [ "anymap3", - "base64 0.21.7", "brotli", "bytes", "flate2", @@ -5764,40 +5616,17 @@ dependencies = [ "native-tls", "once_cell", "prometheus 0.14.0", - "rand 0.9.4", "spacetimedb-client-api-messages", "spacetimedb-data-structures", "spacetimedb-lib", "spacetimedb-metrics", "spacetimedb-query-builder", "spacetimedb-sats", - "spacetimedb-schema", "thiserror 1.0.69", "tokio", "tokio-tungstenite 0.27.0", ] -[[package]] -name = "spacetimedb-sql-parser" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b9908b30e69222970fd955e30235fe977c8fa12010d630949c77f603abf54a2" -dependencies = [ - "derive_more", - "spacetimedb-lib", - "sqlparser", - "thiserror 1.0.69", -] - -[[package]] -name = "sqlparser" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0272b7bb0a225320170c99901b4b5fb3a4384e255a7f2cc228f61e2ba3893e75" -dependencies = [ - "log", -] - [[package]] name = "sse-stream" version = "0.2.5" @@ -5823,50 +5652,19 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "strip-ansi-escapes" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" -dependencies = [ - "vte", -] - [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" -dependencies = [ - "strum_macros 0.25.3", -] - [[package]] name = "strum" version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros 0.26.4", -] - -[[package]] -name = "strum_macros" -version = "0.25.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.118", + "strum_macros", ] [[package]] @@ -5989,16 +5787,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", -] - -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", + "windows-sys 0.59.0", ] [[package]] @@ -6262,11 +6051,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", - "serde_core", - "serde_spanned", "toml_datetime", "toml_parser", - "toml_writer", "winnow", ] @@ -6279,12 +6065,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" - [[package]] name = "tonic" version = "0.14.6" @@ -6292,7 +6072,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", - "base64 0.22.1", + "base64", "bytes", "http", "http-body", @@ -6546,15 +6326,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -6650,15 +6421,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "vte" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" -dependencies = [ - "memchr", -] - [[package]] name = "walkdir" version = "2.5.0" @@ -7111,7 +6873,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7344,7 +7106,7 @@ version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ - "ahash", + "ahash 0.8.12", "android-activity", "atomic-waker", "bitflags 2.13.0", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 00a37db27..bd756b6dd 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -124,9 +124,9 @@ sha1 = "0.10" sha2 = "0.10" socket2 = "0.6" symphonia = { version = "0.5", default-features = false, features = ["mp3"] } -spacetimedb = "=2.7.0" -spacetimedb-sdk = "=2.7.0" -spacetimedb-lib = { version = "=2.7.0", default-features = false } +spacetimedb = "=2.8.3" +spacetimedb-sdk = "=2.8.3" +spacetimedb-lib = { version = "=2.8.3", default-features = false } time = "0.3" tokio = "1" tokio-stream = "0.1" diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 49751fd92..5afb9ccf2 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -657,6 +657,10 @@ pub async fn admin_list_editor_assets( Extension(_admin): Extension, Query(query): Query, ) -> Result, AppError> { + state + .refresh_auth_store_from_spacetime() + .await + .map_err(map_admin_spacetime_error)?; let page_size = query .limit .unwrap_or(ADMIN_EDITOR_ASSET_DEFAULT_LIMIT) diff --git a/server-rs/crates/api-server/src/admin_recharge.rs b/server-rs/crates/api-server/src/admin_recharge.rs index 28ad682ec..28492509e 100644 --- a/server-rs/crates/api-server/src/admin_recharge.rs +++ b/server-rs/crates/api-server/src/admin_recharge.rs @@ -64,6 +64,7 @@ pub async fn admin_list_recharge_orders( Extension(_admin): Extension, Query(query): Query, ) -> Result, Response> { + refresh_auth_projection(&state, &request_context).await?; let user_id = resolve_optional_user_id(&state, query.user_id, query.public_user_code) .map_err(|error| error_response(&request_context, error))?; let input = build_runtime_profile_recharge_order_admin_list_input( @@ -112,6 +113,7 @@ pub async fn admin_get_user_detail( Extension(admin): Extension, Query(query): Query, ) -> Result, Response> { + refresh_auth_projection(&state, &request_context).await?; let user = resolve_user(&state, query.user_id, query.public_user_code) .map_err(|error| error_response(&request_context, error))?; let wallet_detail = state @@ -181,6 +183,7 @@ pub async fn admin_reconcile_user_consumption( Extension(admin): Extension, Json(payload): Json, ) -> Result, Response> { + refresh_auth_projection(&state, &request_context).await?; let user = resolve_user(&state, Some(payload.user_id), None) .map_err(|error| error_response(&request_context, error))?; let input = build_runtime_profile_wallet_consumption_reconcile_input( @@ -589,6 +592,7 @@ pub async fn admin_update_wallet_restriction( Extension(admin): Extension, Json(payload): Json, ) -> Result, Response> { + refresh_auth_projection(&state, &request_context).await?; let user = resolve_user(&state, Some(payload.user_id), None) .map_err(|error| error_response(&request_context, error))?; let input = build_runtime_profile_wallet_manual_restriction_upsert_input( @@ -1040,6 +1044,22 @@ fn map_hold(hold: RuntimeProfileRechargeRefundHoldSnapshot) -> AdminRechargeRefu } } +async fn refresh_auth_projection( + state: &AppState, + request_context: &RequestContext, +) -> Result<(), Response> { + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + error_response( + request_context, + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("刷新用户认证信息失败:{error}")), + ) + }) +} + fn resolve_optional_user_id( state: &AppState, user_id: Option, diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index 11e7369eb..e0ce18d6d 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -499,87 +499,127 @@ async fn refund_asset_operation_points_with_job_id( external_generation_claim_attempt: Option, ) -> Result<(), AppError> { let created_at_micros = current_utc_micros(); - let metadata_json = wallet_metadata_json( - external_generation_job_id.as_deref(), + let current_attempt_is_owned_by_failure_transaction = + external_generation_job_id.as_deref().is_some_and(|job_id| { + current_external_generation_billing_context().is_some_and(|context| { + context.job_id == job_id + && Some(context.claim_attempt) == external_generation_claim_attempt + }) + }); + if current_attempt_is_owned_by_failure_transaction { + // 队列当前 attempt 的 refund 由 fail_external_generation_job transaction 原子写入 + // SpacetimeDB outbox;这里不能先写另一笔独立退款,避免任务成功写回后被误退。 + return Ok(()); + } + let settlement_reason = if external_generation_job_id.is_some() { + "stale_attempt_recovery" + } else { + "asset_operation_failed" + }; + let enqueue_input = module_runtime::build_runtime_profile_wallet_refund_outbox_enqueue_input( + owner_user_id.clone(), + points_cost, + ledger_id.clone(), + created_at_micros, + asset_kind.clone(), + asset_id.clone(), + settlement_reason.to_string(), + external_generation_job_id.clone(), external_generation_claim_attempt, - ); - let result = state + ) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "profile-wallet-refund-outbox", + "message": error.to_string(), + })) + })?; + let enqueue_result = state .spacetime_client() - .refund_profile_wallet_points_with_metadata( - owner_user_id.clone(), - points_cost, - ledger_id.clone(), - created_at_micros, - metadata_json, - ) + .enqueue_profile_wallet_refund_outbox(enqueue_input) .await; - if let Err(error) = result { - let refund_error = error.to_string(); - let app_error = map_asset_operation_wallet_error(error); - if let Some(outbox) = state.wallet_refund_outbox() { - match outbox - .enqueue(WalletRefundOutboxRecord { - owner_user_id: owner_user_id.clone(), - amount: points_cost, - ledger_id: ledger_id.clone(), - created_at_micros, - asset_kind: asset_kind.clone(), - asset_id: asset_id.clone(), - external_generation_job_id: external_generation_job_id.clone(), - }) - .await - { - Ok(WalletRefundOutboxEnqueueOutcome::Enqueued) => { - tracing::warn!( - owner_user_id, - asset_kind, - asset_id, - external_generation_job_id, - ledger_id, - error = %refund_error, - "资产操作失败后的泥点退款立即执行失败,已写入 wallet refund outbox" - ); - } - Ok(WalletRefundOutboxEnqueueOutcome::Dropped { reason }) => { - tracing::error!( - owner_user_id, - asset_kind, - asset_id, - external_generation_job_id, - ledger_id, - reason, - error = %refund_error, - "资产操作失败后的泥点退款立即执行失败,且 wallet refund outbox 因容量限制丢弃" - ); - } - Err(outbox_error) => { - tracing::error!( - owner_user_id, - asset_kind, - asset_id, - external_generation_job_id, - ledger_id, - refund_error = %refund_error, - outbox_error = %outbox_error, - "资产操作失败后的泥点退款立即执行失败,且写入 wallet refund outbox 失败" - ); - } - } - } else { - tracing::error!( + match enqueue_result { + Ok(_) => { + tracing::info!( owner_user_id, asset_kind, asset_id, external_generation_job_id, external_generation_claim_attempt, ledger_id, - error = %refund_error, - "资产操作失败后的泥点退款失败,且 wallet refund outbox 未启用" + "资产操作失败后的泥点退款已写入 SpacetimeDB refund outbox" ); + Ok(()) } - return Err(app_error); + Err(error) if should_use_wallet_refund_emergency_spool(&error) => { + let refund_error = error.to_string(); + let app_error = map_asset_operation_wallet_error(error); + if let Some(outbox) = state.wallet_refund_outbox() { + match outbox + .enqueue(WalletRefundOutboxRecord { + owner_user_id: owner_user_id.clone(), + amount: points_cost, + ledger_id: ledger_id.clone(), + created_at_micros, + asset_kind: asset_kind.clone(), + asset_id: asset_id.clone(), + settlement_reason: settlement_reason.to_string(), + external_generation_job_id: external_generation_job_id.clone(), + external_generation_claim_attempt, + }) + .await + { + Ok(WalletRefundOutboxEnqueueOutcome::Enqueued) => { + tracing::warn!( + owner_user_id, + asset_kind, + asset_id, + external_generation_job_id, + ledger_id, + error = %refund_error, + "SpacetimeDB refund outbox 不可达,已写入本机 emergency spool" + ); + } + Ok(WalletRefundOutboxEnqueueOutcome::OverflowEnqueued { reason }) => { + tracing::error!( + owner_user_id, + asset_kind, + asset_id, + external_generation_job_id, + ledger_id, + reason, + error = %refund_error, + "SpacetimeDB refund outbox 不可达,退款已写入本机 emergency spool overflow 文件;需监控并尽快恢复库内队列" + ); + } + Err(outbox_error) => { + tracing::error!( + owner_user_id, + asset_kind, + asset_id, + external_generation_job_id, + ledger_id, + refund_error = %refund_error, + outbox_error = %outbox_error, + "SpacetimeDB refund outbox 不可达,且写入本机 emergency spool 失败" + ); + } + } + } else { + tracing::error!( + owner_user_id, + asset_kind, + asset_id, + external_generation_job_id, + external_generation_claim_attempt, + ledger_id, + error = %refund_error, + "SpacetimeDB refund outbox 不可达,且本机 emergency spool 未启用" + ); + } + Err(app_error) + } + Err(error) => Err(map_asset_operation_wallet_error(error)), } - Ok(()) } fn current_external_generation_billing_context() -> Option { @@ -683,6 +723,22 @@ pub(crate) fn should_skip_asset_operation_billing_for_connectivity( } } +fn should_use_wallet_refund_emergency_spool(error: &SpacetimeClientError) -> bool { + match error { + SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout(_) => true, + SpacetimeClientError::Build(message) + | SpacetimeClientError::Procedure(message) + | SpacetimeClientError::Runtime(message) => { + message.contains("503") + || message.contains("Service Unavailable") + || message.contains("Failed to connect") + || message.contains("WebSocket") + || message.contains("连接已断开") + || message.contains("连接在返回结果前已断开") + } + } +} + fn current_utc_micros() -> i64 { time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64 / 1_000 } @@ -838,6 +894,24 @@ mod tests { )); } + #[test] + fn wallet_refund_emergency_spool_requires_database_unavailability() { + assert!(should_use_wallet_refund_emergency_spool( + &SpacetimeClientError::ConnectDropped + )); + assert!(should_use_wallet_refund_emergency_spool( + &SpacetimeClientError::Runtime("503 Service Unavailable".to_string()) + )); + assert!(!should_use_wallet_refund_emergency_spool( + &SpacetimeClientError::Procedure( + "No such procedure: enqueue_profile_wallet_refund_outbox_and_return".to_string(), + ) + )); + assert!(!should_use_wallet_refund_emergency_spool( + &SpacetimeClientError::Procedure("泥点余额不足".to_string()) + )); + } + #[test] fn asset_operation_wallet_insufficient_balance_is_public_message() { for domain_message in [ diff --git a/server-rs/crates/api-server/src/auth.rs b/server-rs/crates/api-server/src/auth.rs index cac3b201d..7ccaf0e5f 100644 --- a/server-rs/crates/api-server/src/auth.rs +++ b/server-rs/crates/api-server/src/auth.rs @@ -19,6 +19,7 @@ use serde_json::{Value, json}; use shared_contracts::auth::RuntimeGuestTokenResponse; #[cfg(any())] use shared_kernel::{format_rfc3339, new_uuid_simple_string}; +#[cfg(test)] use time::OffsetDateTime; use tracing::warn; @@ -145,6 +146,16 @@ pub async fn require_bearer_auth( let Some(authenticated) = authenticate_request(&state, headers, request_id).await? else { return Err(AppError::from_status(StatusCode::UNAUTHORIZED)); }; + // JWT 会话校验走 SpacetimeDB;随后刷新用户/身份投影,保证所有受保护路由在 + // 读取进程内工作集时都不会依赖粘性会话命中创建或更新它的 API 节点。 + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + warn!(error = %error, "受保护请求刷新认证投影失败"); + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("认证状态服务暂不可用") + })?; request.extensions_mut().insert(authenticated.clone()); let mut response = next.run(request).await; @@ -236,54 +247,77 @@ async fn authenticate_request( ); AppError::from_status(StatusCode::UNAUTHORIZED) })?; - let current_user = state - .auth_user_service() - .get_user_by_id(claims.user_id()) - .map_err(|error| { - warn!( - %request_id, - error = %error, - "Bearer JWT 用户快照读取失败" - ); - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - })?; - let Some(current_user) = current_user else { - warn!( - %request_id, - user_id = %claims.user_id(), - "Bearer JWT 对应用户不存在" - ); - return Err(AppError::from_status(StatusCode::UNAUTHORIZED)); - }; - if current_user.token_version != claims.token_version() { - warn!( - %request_id, - user_id = %claims.user_id(), - token_version = claims.token_version(), - current_token_version = current_user.token_version, - "Bearer JWT 版本已失效" - ); - return Err(AppError::from_status(StatusCode::UNAUTHORIZED) - .with_message("当前登录态已失效,请重新登录")); - } - + #[cfg(not(test))] let session_is_active = state - .refresh_session_service() - .is_session_active_for_user( - claims.user_id(), - claims.session_id(), - OffsetDateTime::now_utc(), - ) + .spacetime_client() + .validate_auth_session(spacetime_client::AuthSessionValidationRecordInput { + user_id: claims.user_id().to_string(), + session_id: claims.session_id().to_string(), + token_version: claims.token_version(), + }) + .await .map_err(|error| { warn!( %request_id, user_id = %claims.user_id(), session_id = %claims.session_id(), error = %error, - "Bearer JWT refresh session 状态读取失败" + "Bearer JWT SpacetimeDB 会话状态读取失败" ); AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) })?; + + #[cfg(test)] + let session_is_active = { + let current_user = state + .auth_user_service() + .get_user_by_id(claims.user_id()) + .map_err(|error| { + warn!( + %request_id, + error = %error, + "Bearer JWT 用户快照读取失败" + ); + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + let Some(current_user) = current_user else { + warn!( + %request_id, + user_id = %claims.user_id(), + "Bearer JWT 对应用户不存在" + ); + return Err(AppError::from_status(StatusCode::UNAUTHORIZED)); + }; + if current_user.token_version != claims.token_version() { + warn!( + %request_id, + user_id = %claims.user_id(), + token_version = claims.token_version(), + current_token_version = current_user.token_version, + "Bearer JWT 版本已失效" + ); + return Err(AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("当前登录态已失效,请重新登录")); + } + + state + .refresh_session_service() + .is_session_active_for_user( + claims.user_id(), + claims.session_id(), + OffsetDateTime::now_utc(), + ) + .map_err(|error| { + warn!( + %request_id, + user_id = %claims.user_id(), + session_id = %claims.session_id(), + error = %error, + "Bearer JWT refresh session 状态读取失败" + ); + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })? + }; if !session_is_active { warn!( %request_id, diff --git a/server-rs/crates/api-server/src/auth_public_user.rs b/server-rs/crates/api-server/src/auth_public_user.rs index e07b4f6ab..d7f676dfb 100644 --- a/server-rs/crates/api-server/src/auth_public_user.rs +++ b/server-rs/crates/api-server/src/auth_public_user.rs @@ -15,6 +15,13 @@ pub async fn get_public_user_by_code( Extension(request_context): Extension, Path(code): Path, ) -> Result, AppError> { + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新认证状态失败:{error}")) + })?; let user = state .password_entry_service() .get_user_by_public_user_code(&code) @@ -41,6 +48,14 @@ pub async fn get_public_user_by_id( return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("用户 ID 不能为空")); } + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新认证状态失败:{error}")) + })?; + let user = state .auth_user_service() .get_user_by_id(user_id) diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 7fe08a927..bfe357866 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -4946,7 +4946,16 @@ async fn extract_animation_frames_from_preview_video( } fn create_animation_temp_dir() -> Result { - let temp_dir = std::env::temp_dir().join(format!( + // 中文注释:生产不要依赖 systemd PrivateTmp 的私有挂载;宿主机清理悬空 + // /tmp 时会让新任务得到 ENOENT。服务 unit 已明确放行该运行态目录。 + let base_dir = std::env::var_os("GENARRATIVE_CHARACTER_ANIMATION_TEMP_DIR") + .map(PathBuf::from) + .or_else(|| { + (std::env::var("GENARRATIVE_ENV").ok().as_deref() == Some("production")) + .then(|| PathBuf::from("/var/lib/genarrative/character-animation-tmp")) + }) + .unwrap_or_else(std::env::temp_dir); + let temp_dir = base_dir.join(format!( "genarrative-character-animation-{}", current_utc_micros() )); diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index b27804a9d..fa80d9b32 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -14,6 +14,7 @@ use spacetime_client::{ ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationQueueWakeSubscription, }; use tokio::{ + sync::{OwnedSemaphorePermit, Semaphore}, task::{JoinHandle, JoinSet}, time::sleep, }; @@ -92,6 +93,10 @@ pub(crate) async fn run_external_generation_worker(state: AppState) -> Result<() let concurrency = state.config.external_generation_worker_concurrency.max(1); let poll_interval = state.config.external_generation_worker_poll_interval; let lease = state.config.external_generation_worker_lease; + // 超时任务不能立即取消(在途 procedure 仍可能写回),因此执行容量必须同时 + // 约束 active 与 detached work;否则每次超时都会释放 tasks 槽位,实际内存占用 + // 会超过配置并发。 + let work_slots = std::sync::Arc::new(Semaphore::new(concurrency)); let mut tasks = JoinSet::new(); let mut shutdown = external_generation_worker_shutdown_signal(); let mut queue_wake = None; @@ -113,16 +118,29 @@ pub(crate) async fn run_external_generation_worker(state: AppState) -> Result<() ); loop { + // 持续有队列任务时不会进入等待分支,因此必须在每轮主动回收已完成的 + // JoinHandle;否则 permit 虽已归还,JoinSet 仍会保留每个历史任务的句柄。 + reap_finished_external_generation_worker_tasks(&mut tasks); ensure_external_generation_queue_wake_subscription(&state, &mut queue_wake).await; - while tasks.len() >= concurrency { - if await_worker_task_or_shutdown(&mut tasks, &mut shutdown).await { - drain_external_generation_worker_tasks(&mut tasks).await; - return Ok(()); + while work_slots.available_permits() == 0 { + tokio::select! { + _ = shutdown.as_mut() => { + drain_external_generation_worker_tasks(&mut tasks).await; + return Ok(()); + } + permit = work_slots.clone().acquire_owned() => { + if permit.is_err() { + drain_external_generation_worker_tasks(&mut tasks).await; + return Ok(()); + } + // 只用 acquire 作为容量变化唤醒信号,许可立即归还;真正领取任务 + // 时在下方按返回的 job 数量逐个 try_acquire。 + } } } - let available = concurrency.saturating_sub(tasks.len()).max(1); + let available = work_slots.available_permits().max(1); let now_micros = current_utc_micros(); let lease_expires_at_micros = now_micros.saturating_add(duration_micros_i64(lease)); @@ -178,9 +196,13 @@ pub(crate) async fn run_external_generation_worker(state: AppState) -> Result<() for job in jobs { let state = state.clone(); let worker_id = worker_id.clone(); + let permit = work_slots + .clone() + .try_acquire_owned() + .expect("claimed job must have an execution capacity permit"); tasks.spawn(async move { if let Err(error) = - process_external_generation_job(state, worker_id, lease, job).await + process_external_generation_job(state, worker_id, lease, job, permit).await { error!(error = %error, "external generation worker 执行任务失败"); } @@ -255,13 +277,11 @@ async fn await_worker_task(tasks: &mut JoinSet<()>) { } } -async fn await_worker_task_or_shutdown( - tasks: &mut JoinSet<()>, - shutdown: &mut ExternalGenerationShutdownSignal, -) -> bool { - tokio::select! { - _ = shutdown.as_mut() => true, - _ = await_worker_task(tasks) => false, +fn reap_finished_external_generation_worker_tasks(tasks: &mut JoinSet<()>) { + while let Some(result) = tasks.try_join_next() { + if let Err(error) = result { + error!(error = %error, "external generation worker 子任务 panic"); + } } } @@ -326,6 +346,7 @@ async fn process_external_generation_job( worker_id: String, lease: Duration, job: ExternalGenerationJobRecord, + permit: OwnedSemaphorePermit, ) -> Result<(), String> { let heartbeat_interval = external_generation_worker_heartbeat_interval(lease); let job_timeout = external_generation_worker_job_timeout(&state.config, job.job_kind.as_str()); @@ -377,13 +398,14 @@ async fn process_external_generation_job( job_id = %job.job_id, job_kind = %job.job_kind, timeout_seconds = job_timeout.as_secs(), - "external generation worker 任务超过执行预算,停止续租并释放 worker 槽位,在途执行交由租约仲裁" + "external generation worker 任务超过执行预算,停止续租并保留 worker 槽位,在途执行交由租约仲裁" ); detach_external_generation_work_until_lease_expiry( work_handle, &job, lease, "任务超过执行预算", + Some(permit), ); Err(message) } @@ -393,6 +415,7 @@ async fn process_external_generation_job( &job, lease, "任务租约续期失败", + Some(permit), ); Err(error) } @@ -417,6 +440,7 @@ fn detach_external_generation_work_until_lease_expiry( job: &ExternalGenerationJobRecord, lease: Duration, reason: &'static str, + permit: Option, ) { let job_id = job.job_id.clone(); let job_kind = job.job_kind.clone(); @@ -445,6 +469,9 @@ fn detach_external_generation_work_until_lease_expiry( ), Err(_) => { work_handle.abort(); + // 仅调用 abort 不会从 JoinHandle/JoinSet 中消费完成结果;等待被取消 + // 的 handle,确保 permit 与任务句柄在同一生命周期内一起释放。 + let _ = work_handle.await; warn!( job_id = %job_id, job_kind = %job_kind, @@ -454,6 +481,9 @@ fn detach_external_generation_work_until_lease_expiry( ); } } + // 保持执行许可直到 work 真正结束或被取消,避免超时任务脱管后继续 + // 累积图片/音频响应占用。 + drop(permit); }); } @@ -1972,6 +2002,7 @@ mod tests { &job, Duration::from_millis(200), "任务超过执行预算", + None, ); tokio::time::sleep(Duration::from_millis(100)).await; @@ -1981,6 +2012,61 @@ mod tests { ); } + #[tokio::test] + async fn worker_detached_work_keeps_execution_slot_until_finished() { + let slots = std::sync::Arc::new(tokio::sync::Semaphore::new(1)); + let permit = slots + .clone() + .acquire_owned() + .await + .expect("the only execution slot should be available"); + let work_handle = tokio::spawn(async { + tokio::time::sleep(Duration::from_millis(20)).await; + Ok(()) + }); + let job = external_generation_job_record_fixture(Some("lease-1")); + + detach_external_generation_work_until_lease_expiry( + work_handle, + &job, + Duration::from_millis(200), + "任务超过执行预算", + Some(permit), + ); + + assert!( + slots.try_acquire().is_err(), + "脱管 work 完成前不得重新领取执行容量" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + slots.try_acquire().is_ok(), + "脱管 work 完成后应归还执行容量" + ); + } + + #[tokio::test] + async fn worker_reaps_completed_tasks_while_queue_remains_busy() { + let mut tasks = JoinSet::new(); + let completed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + const TASK_COUNT: usize = 128; + for _ in 0..TASK_COUNT { + let completed = completed.clone(); + tasks.spawn(async move { + completed.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + }); + } + + while completed.load(std::sync::atomic::Ordering::SeqCst) < TASK_COUNT { + tokio::task::yield_now().await; + } + assert_eq!(tasks.len(), TASK_COUNT); + + reap_finished_external_generation_worker_tasks(&mut tasks); + + assert!(tasks.is_empty(), "已完成任务的 JoinHandle 应在每轮被回收"); + } + #[tokio::test] async fn worker_detached_work_is_aborted_after_lease_arbitration_window() { let connection = std::sync::Arc::new(tokio::sync::Semaphore::new(1)); @@ -1999,6 +2085,7 @@ mod tests { &job, Duration::from_millis(10), "任务超过执行预算", + None, ); let reacquired = diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 1d73da766..441936e1e 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -529,21 +529,24 @@ async fn finalize_shutdown(context: ShutdownContext) { } if let Some(outbox) = context.wallet_refund_outbox { - info!(timeout_ms, "api-server 退出前 flush wallet refund outbox"); + info!( + timeout_ms, + "api-server 退出前 flush wallet refund emergency spool" + ); match timeout(context.outbox_flush_timeout, outbox.flush_for_shutdown()).await { Ok(Ok(())) => { - info!("api-server 退出前 wallet refund outbox flush 完成"); + info!("api-server 退出前 wallet refund emergency spool flush 完成"); } Ok(Err(error)) => { warn!( error = %error, - "api-server 退出前 wallet refund outbox flush 未完成,已保留本地文件等待下次启动重试" + "api-server 退出前 wallet refund emergency spool flush 未完成,已保留本地文件等待下次启动重试" ); } Err(_) => { warn!( timeout_ms, - "api-server 退出前 wallet refund outbox flush 超时,已保留本地文件等待下次启动重试" + "api-server 退出前 wallet refund emergency spool flush 超时,已保留本地文件等待下次启动重试" ); } } @@ -557,6 +560,7 @@ fn spawn_common_app_state_background_workers(state: &AppState) { if let Some(outbox) = state.wallet_refund_outbox() { outbox.spawn_worker(); } + state.profile_wallet_refund_outbox_worker().spawn_worker(); } fn spawn_http_app_state_background_workers(state: &AppState, process_role: ProcessRole) { diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index f2e82660e..d1acc699c 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -27,6 +27,13 @@ pub async fn password_entry( headers: HeaderMap, Json(payload): Json, ) -> Result { + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新认证状态失败:{error}")) + })?; let input = PasswordEntryInput { country_code: payload.country_code, pure_phone_number: payload.pure_phone_number, diff --git a/server-rs/crates/api-server/src/password_management.rs b/server-rs/crates/api-server/src/password_management.rs index 70f620791..068e65c7b 100644 --- a/server-rs/crates/api-server/src/password_management.rs +++ b/server-rs/crates/api-server/src/password_management.rs @@ -9,6 +9,7 @@ use shared_contracts::auth::{ PasswordChangeRequest, PasswordChangeResponse, PasswordResetRequest, PasswordResetResponse, }; use time::OffsetDateTime; +use tracing::warn; use crate::{ api_response::json_success_body, @@ -81,7 +82,17 @@ pub async fn reset_password( ); } - let result = state + // reset_password 消费的是跨节点共享的短期验证码;先恢复正式投影, + // 避免发码节点与消费节点的本机工作集不一致。 + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新短信验证码状态失败:{error}")) + })?; + + let result = match state .phone_auth_service() .reset_password( ResetPasswordInput { @@ -93,7 +104,17 @@ pub async fn reset_password( OffsetDateTime::now_utc(), ) .await - .map_err(map_phone_auth_error)?; + { + Ok(result) => result, + Err(error) => { + if let Err(sync_error) = state.sync_auth_store_tables_to_spacetime().await { + warn!(error = %sync_error, "重置密码失败后的短信验证码状态同步失败"); + return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message("同步短信验证码状态失败")); + } + return Err(map_phone_auth_error(error)); + } + }; let session_client = resolve_session_client_context(&headers); let signed_session = create_auth_session( &state, diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index a0e9cd753..37e77acec 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -50,16 +50,33 @@ pub async fn send_phone_code( phone_input_masked = phone_input_masked.as_str(), "收到手机号验证码发送请求" ); + let send_input = SendPhoneCodeInput { + country_code: payload.country_code, + pure_phone_number: payload.pure_phone_number, + scene: scene.clone(), + }; + let send_now = OffsetDateTime::now_utc(); + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新短信验证码状态失败:{error}")) + })?; + state + .phone_auth_service() + .reserve_code_send(&send_input, send_now) + .map_err(map_phone_auth_error)?; + state + .sync_auth_store_tables_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("占用短信验证码发送窗口失败:{error}")) + })?; let result = match state .phone_auth_service() - .send_code( - SendPhoneCodeInput { - country_code: payload.country_code, - pure_phone_number: payload.pure_phone_number, - scene: scene.clone(), - }, - OffsetDateTime::now_utc(), - ) + .send_code_after_authoritative_reservation(send_input, send_now) .await { Ok(result) => { @@ -91,6 +108,14 @@ pub async fn send_phone_code( } }; + state + .sync_auth_store_tables_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步短信验证码状态失败:{error}")) + })?; + Ok(json_success_body( Some(&request_context), PhoneSendCodeResponse { @@ -114,19 +139,44 @@ pub async fn phone_login( AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用") ); } - let invite_code = payload.invite_code.clone(); - let result = match state - .phone_auth_service() - .login( - PhoneLoginInput { - country_code: payload.country_code, - pure_phone_number: payload.pure_phone_number, - verify_code: payload.code, - }, - OffsetDateTime::now_utc(), - ) + state + .refresh_auth_store_from_spacetime() .await - { + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新短信验证码状态失败:{error}")) + })?; + let invite_code = payload.invite_code.clone(); + let login_input = PhoneLoginInput { + country_code: payload.country_code, + pure_phone_number: payload.pure_phone_number, + verify_code: payload.code, + }; + let login_now = OffsetDateTime::now_utc(); + let login_result = state + .phone_auth_service() + .login(login_input.clone(), login_now) + .await; + let login_result = match login_result { + Err(PhoneAuthError::VerifyCodeNotFound) => { + if let Err(sync_error) = state.refresh_auth_store_from_spacetime().await { + warn!( + request_id = request_context.request_id(), + operation = request_context.operation(), + error = %sync_error, + "手机号验证码未命中后的认证投影刷新失败" + ); + return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message("刷新短信验证码状态失败")); + } + state + .phone_auth_service() + .login(login_input, login_now) + .await + } + result => result, + }; + let result = match login_result { Ok(result) => { info!( request_id = request_context.request_id(), @@ -142,6 +192,16 @@ pub async fn phone_login( result } Err(error) => { + if let Err(sync_error) = state.sync_auth_store_tables_to_spacetime().await { + warn!( + request_id = request_context.request_id(), + operation = request_context.operation(), + error = %sync_error, + "手机号验证码登录失败后的状态同步失败" + ); + return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message("同步短信验证码状态失败")); + } warn!( request_id = request_context.request_id(), operation = request_context.operation(), diff --git a/server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs b/server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs index 5804203c3..070ac5752 100644 --- a/server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs +++ b/server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs @@ -265,6 +265,14 @@ async fn process_expired_virtual_payment_order( state: &AppState, order: &RuntimeProfileRechargeOrderRecord, ) -> Result<(), ExpirationCompensationError> { + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + ExpirationCompensationError::Runtime(format!( + "failed to refresh auth projection for virtual payment query: {error}" + )) + })?; let identity = state .wechat_auth_service() .get_identity_by_user_id(&order.user_id) diff --git a/server-rs/crates/api-server/src/refresh_session.rs b/server-rs/crates/api-server/src/refresh_session.rs index 4cdd87bb8..c6c783f67 100644 --- a/server-rs/crates/api-server/src/refresh_session.rs +++ b/server-rs/crates/api-server/src/refresh_session.rs @@ -39,6 +39,16 @@ pub async fn refresh_session( let next_refresh_token = platform_auth::create_refresh_session_token(); let next_refresh_token_hash = hash_refresh_session_token(&next_refresh_token); + // refresh_session 是跨节点的正式认证入口;先加载最新投影,再在本机工作集执行领域轮换, + // 避免请求落到旧节点时把合法 refresh cookie 误判为不存在。 + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新认证状态失败:{error}")) + })?; + let rotated = match state.refresh_session_service().rotate_session( RotateRefreshSessionInput { refresh_token_hash: refresh_token_hash.clone(), diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 290e5b12e..67625908d 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1650,6 +1650,13 @@ async fn resolve_wechat_identity_for_payment( state: &AppState, user_id: &str, ) -> Result { + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新微信认证状态失败:{error}")) + })?; if let Some(identity) = state .wechat_auth_service() .get_identity_by_user_id(user_id) diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 501182c91..284b437cb 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -8,7 +8,7 @@ use std::{ fmt, sync::{ Arc, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}, }, }; @@ -35,7 +35,7 @@ use spacetime_client::{ SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError, SpacetimeClientHealthSnapshot, }; use time::OffsetDateTime; -use tokio::sync::{Semaphore, broadcast}; +use tokio::sync::{Mutex as AsyncMutex, Semaphore, broadcast}; use tracing::{info, warn}; use crate::config::AppConfig; @@ -45,7 +45,7 @@ use crate::editor_generation_config::{ EditorGenerationPricingUnit, }; use crate::tracking_outbox::TrackingOutbox; -use crate::wallet_refund_outbox::WalletRefundOutbox; +use crate::wallet_refund_outbox::{ProfileWalletRefundOutboxWorker, WalletRefundOutbox}; use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error}; use crate::wechat::provider::build_wechat_provider; use crate::work_author::{ @@ -273,6 +273,14 @@ pub struct AppStateInner { oss_client: Option, #[cfg_attr(test, allow(dead_code))] auth_store: InMemoryAuthStore, + /// 当前进程工作集所基于的正式认证投影版本;跨节点写入使用它做 CAS。 + #[cfg_attr(test, allow(dead_code))] + auth_projection_version: AtomicI64, + /// 最近一次确认写入正式投影时对应的工作集 revision;不一致表示有待重试的本地变更。 + #[cfg_attr(test, allow(dead_code))] + auth_projection_synced_revision: AtomicU64, + #[cfg_attr(test, allow(dead_code))] + auth_projection_sync_lock: AsyncMutex<()>, password_entry_service: PasswordEntryService, refresh_session_service: RefreshSessionService, auth_user_service: AuthUserService, @@ -289,6 +297,7 @@ pub struct AppStateInner { puzzle_gallery_cache: PuzzleGalleryCache, tracking_outbox: Option>, wallet_refund_outbox: Option>, + profile_wallet_refund_outbox_worker: Arc, editor_generation_pricing_store: EditorGenerationPricingStore, llm_client: Option, vector_engine_llm_client: Option, @@ -505,12 +514,13 @@ impl AppState { pub fn new_with_empty_auth_store(config: AppConfig) -> Result { // 中文注释:api-server 不再把本地 auth-store.json 当作用户认证真相源,启动恢复只允许来自 SpacetimeDB。 - Self::new_with_auth_store(config, InMemoryAuthStore::default()) + Self::new_with_auth_store(config, InMemoryAuthStore::default(), 0) } fn new_with_auth_store( config: AppConfig, auth_store: InMemoryAuthStore, + auth_projection_version: i64, ) -> Result { let auth_jwt_config = JwtConfig::new( config.jwt_issuer.clone(), @@ -561,7 +571,11 @@ impl AppState { ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ) .map_err(|error| AppStateInitError::AuthStore(error.to_string()))?; - let phone_auth_service = PhoneAuthService::new(auth_store.clone(), sms_provider); + let phone_auth_service = PhoneAuthService::new_with_verify_code_salt( + auth_store.clone(), + sms_provider, + config.jwt_secret.clone(), + ); let wechat_auth_state_service = WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes); let wechat_auth_service = WechatAuthService::new(auth_store.clone()); @@ -577,6 +591,8 @@ impl AppState { let tracking_outbox = TrackingOutbox::from_config(&config, spacetime_client.clone()); let wallet_refund_outbox = WalletRefundOutbox::from_config(&config, spacetime_client.clone()); + let profile_wallet_refund_outbox_worker = + ProfileWalletRefundOutboxWorker::from_config(&config, spacetime_client.clone()); let editor_generation_pricing_store = EditorGenerationPricingStore::load( config.editor_generation_pricing_override_path.clone(), ) @@ -602,6 +618,10 @@ impl AppState { let editor_oss_http_client = build_editor_oss_http_client()?; let http_request_permit_pools = HttpRequestPermitPools::from_config(&config); let (profile_recharge_order_updates, _) = broadcast::channel(128); + // `ensure_orphan_work_owner_user` 只为公开作品作者回退提供进程内占位账号, + // 不属于正式认证投影;将当前工作集 revision 作为已同步起点,首次认证请求会 + // 先按正式投影刷新并自然丢弃该占位账号,避免把它误当成待提交认证变更。 + let initial_auth_store_revision = auth_store.revision(); Ok(Self(Arc::new(AppStateInner { config, @@ -629,6 +649,9 @@ impl AppState { test_external_background_removal_enqueue: Arc::new(Mutex::new(None)), oss_client, auth_store, + auth_projection_version: AtomicI64::new(auth_projection_version), + auth_projection_synced_revision: AtomicU64::new(initial_auth_store_revision), + auth_projection_sync_lock: AsyncMutex::new(()), password_entry_service, refresh_session_service, auth_user_service, @@ -644,6 +667,7 @@ impl AppState { puzzle_gallery_cache: PuzzleGalleryCache::new(), tracking_outbox, wallet_refund_outbox, + profile_wallet_refund_outbox_worker, editor_generation_pricing_store, llm_client, vector_engine_llm_client, @@ -1288,30 +1312,138 @@ impl AppState { return Ok(()); #[cfg(not(test))] - let updated_at_micros = i64::try_from( - OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000, - ) - .map_err(|_| SpacetimeClientError::Runtime("认证状态更新时间超出 i64 范围".to_string()))?; + let _sync_guard = self.auth_projection_sync_lock.lock().await; #[cfg(not(test))] - let projection = self - .auth_store - .export_projection_view(updated_at_micros) - .map_err(SpacetimeClientError::Runtime)?; - // 当前仍由 module-auth 的进程内工作集执行业务规则;这里只用 typed projection 同步正式认证表。 - #[cfg(not(test))] - if let Err(error) = self - .spacetime_client - .sync_auth_store_projection(projection) - .await - { - warn!( - error = %error, - "认证投影同步 SpacetimeDB 正式表失败,当前认证流程中止" - ); - return Err(error); + for attempt in 0..3 { + let base_updated_at_micros = self.auth_projection_version.load(Ordering::Acquire); + let now_updated_at_micros = + i64::try_from(OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000).map_err( + |_| SpacetimeClientError::Runtime("认证状态更新时间超出 i64 范围".to_string()), + )?; + let updated_at_micros = if now_updated_at_micros > base_updated_at_micros { + now_updated_at_micros + } else { + base_updated_at_micros.checked_add(1).ok_or_else(|| { + SpacetimeClientError::Runtime("认证状态版本超出 i64 范围".to_string()) + })? + }; + let (mut projection, attempted_revision) = self + .auth_store + .export_projection_view_with_revision(updated_at_micros) + .map_err(SpacetimeClientError::Runtime)?; + projection.base_updated_at_micros = base_updated_at_micros; + + // 当前仍由 module-auth 的进程内工作集执行业务规则;这里只用 typed projection 同步正式认证表。 + match self + .spacetime_client + .sync_auth_store_projection(projection) + .await + { + Ok(_) => { + self.auth_projection_version + .store(updated_at_micros, Ordering::Release); + if self.auth_store.revision() == attempted_revision { + self.auth_projection_synced_revision + .store(attempted_revision, Ordering::Release); + return Ok(()); + } + warn!( + attempt, + "认证投影同步期间工作集发生变化,将继续同步最新工作集" + ); + continue; + } + Err(error) => { + warn!( + error = %error, + "认证投影同步 SpacetimeDB 正式表失败,当前认证流程中止" + ); + // 当前请求已经失败;只要同步尝试期间没有新的本地变更,恢复为 + // 数据库快照,避免一次 CAS 冲突把本节点永久留在“待同步”状态。 + if self.auth_store.revision() != attempted_revision { + warn!( + "认证投影同步失败期间工作集发生并发变化,跳过自动恢复以避免覆盖未提交变更" + ); + } else if let Ok(current_projection) = self + .spacetime_client + .export_auth_store_projection_from_tables() + .await + { + match self.auth_store.refresh_from_projection_view_if_revision( + current_projection.clone(), + attempted_revision, + ) { + Ok(true) => { + self.auth_projection_version + .store(current_projection.updated_at_micros, Ordering::Release); + self.auth_projection_synced_revision + .store(self.auth_store.revision(), Ordering::Release); + } + Ok(false) => { + warn!( + "认证投影同步冲突期间工作集发生并发变化,跳过自动恢复以避免覆盖未提交变更" + ); + } + Err(refresh_error) => { + warn!( + error = %refresh_error, + "认证投影同步冲突后恢复进程内工作集失败" + ); + } + } + } + return Err(error); + } + } } #[cfg(not(test))] - Ok(()) + Err(SpacetimeClientError::Runtime( + "认证工作集在同步期间持续发生变化,未能完成投影同步".to_string(), + )) + } + + /// 在认证主链路执行前,从正式投影刷新一次本地工作集,避免请求落到另一节点后 + /// 因本机工作集滞后而必须依赖粘性会话才能成功。 + pub async fn refresh_auth_store_from_spacetime(&self) -> Result<(), SpacetimeClientError> { + #[cfg(test)] + return Ok(()); + + #[cfg(not(test))] + { + // 上一次业务操作可能已经改了工作集,但在返回响应前遇到数据库暂时不可用。 + // 先重试提交这份待同步变更,避免只读请求把节点永久卡在 pending 状态。 + if self.auth_projection_synced_revision.load(Ordering::Acquire) + != self.auth_store.revision() + { + self.sync_auth_store_tables_to_spacetime().await?; + } + let _sync_guard = self.auth_projection_sync_lock.lock().await; + let expected_revision = self.auth_store.revision(); + if self.auth_projection_synced_revision.load(Ordering::Acquire) != expected_revision { + return Err(SpacetimeClientError::Runtime( + "认证工作集存在待同步变更,跳过只读刷新".to_string(), + )); + } + let projection = self + .spacetime_client + .export_auth_store_projection_from_tables() + .await?; + let updated_at_micros = projection.updated_at_micros; + let refreshed = self + .auth_store + .refresh_from_projection_view_if_revision(projection, expected_revision) + .map_err(SpacetimeClientError::Runtime)?; + if !refreshed { + return Err(SpacetimeClientError::Runtime( + "认证工作集刷新期间发生并发变更".to_string(), + )); + } + self.auth_projection_version + .store(updated_at_micros, Ordering::Release); + self.auth_projection_synced_revision + .store(self.auth_store.revision(), Ordering::Release); + Ok(()) + } } pub async fn try_restore_auth_store_from_spacetime( @@ -1319,6 +1451,11 @@ impl AppState { ) -> Result { let spacetime_client = SpacetimeClient::new(spacetime_client_config_for_startup_restore(&config)); + initialize_editor_generation_runtime_service_identity_for_startup( + &config, + &spacetime_client, + ) + .await?; let mut spacetime_restore_available = false; let mut restore_errors = Vec::new(); @@ -1332,7 +1469,11 @@ impl AppState { projection, AuthStoreRestoreSource::SpacetimeTables, )? { - let state = Self::new_with_auth_store(config, candidate.auth_store)?; + let state = Self::new_with_auth_store( + config, + candidate.auth_store, + candidate.updated_at_micros.unwrap_or_default(), + )?; info!( source = candidate.source.as_str(), updated_at_micros = candidate.updated_at_micros, @@ -1415,6 +1556,10 @@ impl AppState { self.wallet_refund_outbox.clone() } + pub fn profile_wallet_refund_outbox_worker(&self) -> Arc { + self.profile_wallet_refund_outbox_worker.clone() + } + pub fn llm_client(&self) -> Option<&LlmClient> { self.llm_client.as_ref() } @@ -1845,6 +1990,9 @@ fn auth_store_candidate_from_projection_view( if projection.users.is_empty() && projection.identities.is_empty() && projection.refresh_sessions.is_empty() + && projection.phone_codes.is_empty() + && projection.wechat_states.is_empty() + && projection.updated_at_micros == 0 { return Ok(None); } @@ -1886,6 +2034,34 @@ fn spacetime_client_config_for_startup_restore(config: &AppConfig) -> SpacetimeC } } +async fn initialize_editor_generation_runtime_service_identity_for_startup( + config: &AppConfig, + spacetime_client: &SpacetimeClient, +) -> Result<(), AppStateInitError> { + let pricing_store = + EditorGenerationPricingStore::load(config.editor_generation_pricing_override_path.clone()) + .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; + let fallback = pricing_store + .snapshot() + .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; + let models = editor_generation_pricing_to_records(&fallback) + .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; + spacetime_client + .initialize_editor_generation_pricing_config_if_missing( + editor_generation_pricing_upsert_input( + config, + "system:editor-generation-pricing".to_string(), + models, + crate::editor_project::current_utc_micros(), + ), + ) + .await + .map_err(|error| { + AppStateInitError::DependencyUnavailable(format!("初始化模型定价服务身份失败:{error}")) + })?; + Ok(()) +} + impl fmt::Display for AppStateInitError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/server-rs/crates/api-server/src/tracking_outbox.rs b/server-rs/crates/api-server/src/tracking_outbox.rs index 0f85a68c9..272f08df4 100644 --- a/server-rs/crates/api-server/src/tracking_outbox.rs +++ b/server-rs/crates/api-server/src/tracking_outbox.rs @@ -140,6 +140,10 @@ impl TrackingOutbox { pub fn spawn_worker(self: Arc) { tokio::spawn(async move { + if let Err(error) = self.flush_sealed_files_once().await { + warn!(error = %error, "tracking outbox 启动恢复写入 SpacetimeDB 失败,将保留文件等待重试"); + } + loop { tokio::select! { _ = sleep(self.flush_interval) => { @@ -657,6 +661,45 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn worker_flushes_existing_active_file_immediately_on_startup() { + let dir = test_dir("worker-startup"); + std::fs::create_dir_all(&dir).unwrap(); + let active_path = dir.join(ACTIVE_FILE_NAME); + let record = TrackingOutboxRecord { + event: sample_event("startup-event"), + }; + std::fs::write(&active_path, serde_json::to_vec(&record).unwrap()).unwrap(); + + let outbox = test_outbox(dir.clone(), 500, 1024 * 1024); + outbox.spawn_worker(); + + for _ in 0..100 { + if !active_path.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + assert!( + !active_path.exists(), + "worker should recover active file without waiting for interval" + ); + let sealed_count = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(SEALED_FILE_PREFIX)) + }) + .count(); + assert_eq!(sealed_count, 1); + + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn directory_size_excludes_quarantined_corrupt_files() { let dir = test_dir("directory-size"); diff --git a/server-rs/crates/api-server/src/wallet_refund_outbox.rs b/server-rs/crates/api-server/src/wallet_refund_outbox.rs index 7c27580f3..6d79051ff 100644 --- a/server-rs/crates/api-server/src/wallet_refund_outbox.rs +++ b/server-rs/crates/api-server/src/wallet_refund_outbox.rs @@ -19,6 +19,7 @@ use tracing::{debug, warn}; use crate::config::AppConfig; const PENDING_FILE_PREFIX: &str = "refund-"; +const OVERFLOW_FILE_PREFIX: &str = "refund-overflow-"; const CORRUPT_FILE_PREFIX: &str = "corrupt-"; const TEMP_FILE_PREFIX: &str = "tmp-"; const OUTBOX_FILE_EXTENSION: &str = ".json"; @@ -34,7 +35,72 @@ pub struct WalletRefundOutbox { flush_notify: Arc, } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone)] +pub struct ProfileWalletRefundOutboxWorker { + batch_size: u32, + flush_interval: Duration, + spacetime_client: SpacetimeClient, + worker_id: String, +} + +impl ProfileWalletRefundOutboxWorker { + pub fn from_config(config: &AppConfig, spacetime_client: SpacetimeClient) -> Arc { + Arc::new(Self { + batch_size: config + .wallet_refund_outbox_batch_size + .max(1) + .min(u32::MAX as usize) as u32, + flush_interval: config.wallet_refund_outbox_flush_interval, + spacetime_client, + worker_id: format!("api-server-refund-outbox-{}", std::process::id()), + }) + } + + pub fn spawn_worker(self: Arc) { + tokio::spawn(async move { + self.process_once().await; + loop { + sleep(self.flush_interval).await; + self.process_once().await; + } + }); + } + + async fn process_once(&self) { + match self + .spacetime_client + .process_profile_wallet_refund_outbox(self.worker_id.clone(), self.batch_size) + .await + { + Ok(result) if result.failed_count > 0 => { + warn!( + worker_id = %self.worker_id, + processed_count = result.processed_count, + retry_count = result.retry_count, + failed_count = result.failed_count, + "profile wallet refund outbox 处理部分失败,将按库内 available_at 重试" + ); + } + Ok(result) if result.processed_count > 0 => { + debug!( + worker_id = %self.worker_id, + processed_count = result.processed_count, + "profile wallet refund outbox 已完成库内退款" + ); + } + Ok(_) => {} + Err(error) => { + warn!( + worker_id = %self.worker_id, + error = %error, + "profile wallet refund outbox worker 暂时无法连接 SpacetimeDB" + ); + } + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub(crate) struct WalletRefundOutboxRecord { pub owner_user_id: String, pub amount: u64, @@ -42,14 +108,18 @@ pub(crate) struct WalletRefundOutboxRecord { pub created_at_micros: i64, pub asset_kind: String, pub asset_id: String, + #[serde(default = "default_settlement_reason")] + pub settlement_reason: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub external_generation_job_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_generation_claim_attempt: Option, } #[derive(Debug)] pub enum WalletRefundOutboxEnqueueOutcome { Enqueued, - Dropped { reason: &'static str }, + OverflowEnqueued { reason: &'static str }, } #[derive(Debug)] @@ -84,7 +154,14 @@ impl WalletRefundOutbox { fs::create_dir_all(&self.dir).await?; let pending_path = self.pending_path_for_ledger(&record.ledger_id); - if fs::metadata(&pending_path).await.is_ok() { + let overflow_path = self.overflow_path_for_ledger(&record.ledger_id); + if self + .reuse_existing_pending_file(&pending_path, &record) + .await? + || self + .reuse_existing_pending_file(&overflow_path, &record) + .await? + { self.flush_notify.notify_one(); return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued); } @@ -92,11 +169,12 @@ impl WalletRefundOutbox { let bytes = serde_json::to_vec(&record)?; let line_bytes = bytes.len().min(u64::MAX as usize) as u64; let current_bytes = directory_size_if_exists(&self.dir).unwrap_or(0); - if current_bytes.saturating_add(line_bytes) > self.max_bytes { - return Ok(WalletRefundOutboxEnqueueOutcome::Dropped { - reason: "max_bytes", - }); - } + let overflow = current_bytes.saturating_add(line_bytes) > self.max_bytes; + let target_path = if overflow { + &overflow_path + } else { + &pending_path + }; let temp_path = self.temp_path(); let mut file = OpenOptions::new() @@ -108,19 +186,111 @@ impl WalletRefundOutbox { file.flush().await?; file.sync_data().await?; drop(file); - if fs::metadata(&pending_path).await.is_ok() { + if self + .reuse_existing_pending_file(&pending_path, &record) + .await? + || self + .reuse_existing_pending_file(&overflow_path, &record) + .await? + { let _ = fs::remove_file(&temp_path).await; self.flush_notify.notify_one(); return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued); } - fs::rename(&temp_path, &pending_path).await?; - sync_directory_metadata(&self.dir).await?; - self.flush_notify.notify_one(); - Ok(WalletRefundOutboxEnqueueOutcome::Enqueued) + for _ in 0..2 { + match fs::hard_link(&temp_path, target_path).await { + Ok(()) => { + sync_directory_metadata(&self.dir).await?; + remove_file_and_sync(&temp_path, &self.dir).await?; + self.flush_notify.notify_one(); + return Ok(if overflow { + WalletRefundOutboxEnqueueOutcome::OverflowEnqueued { + reason: "max_bytes", + } + } else { + WalletRefundOutboxEnqueueOutcome::Enqueued + }); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + if self + .reuse_existing_pending_file(target_path, &record) + .await? + { + remove_file_and_sync(&temp_path, &self.dir).await?; + self.flush_notify.notify_one(); + return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued); + } + } + Err(error) => return Err(error.into()), + } + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!( + "refund pending path could not be installed: {}", + target_path.display() + ), + ) + .into()) + } + + async fn reuse_existing_pending_file( + &self, + pending_path: &Path, + expected: &WalletRefundOutboxRecord, + ) -> Result { + let metadata = match fs::metadata(pending_path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error.into()), + }; + if !metadata.is_file() { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!( + "refund pending path is not a regular file: {}", + pending_path.display() + ), + ) + .into()); + } + + match read_refund_record(pending_path).await { + Ok(existing) if existing == *expected => Ok(true), + Ok(existing) if existing.ledger_id == expected.ledger_id => Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!( + "refund ledger {} 已存在但退款事实不一致: {}", + expected.ledger_id, + pending_path.display() + ), + ) + .into()), + Ok(_) => Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("refund ledger hash collision at {}", pending_path.display()), + ) + .into()), + Err(error) if error.is_data_corruption() => { + // Preserve the malformed durable file for inspection, then allow this + // enqueue to install a valid file for the same ledger id. + self.quarantine_file(pending_path).await?; + warn!( + source = %pending_path.display(), + "wallet refund outbox 已隔离损坏 pending 文件,继续写入新的幂等退款记录" + ); + Ok(false) + } + Err(error) => Err(error), + } } pub fn spawn_worker(self: Arc) { tokio::spawn(async move { + if let Err(error) = self.flush_pending_files_once().await { + warn!(error = %error, "wallet refund outbox 启动恢复退款失败,将保留文件等待重试"); + } + loop { tokio::select! { _ = sleep(self.flush_interval) => { @@ -144,6 +314,7 @@ impl WalletRefundOutbox { async fn flush_pending_files_once(&self) -> Result<(), WalletRefundOutboxError> { fs::create_dir_all(&self.dir).await?; + self.recover_temporary_files().await?; let pending_files = self.list_pending_files().await?; for path in pending_files.into_iter().take(self.batch_size) { let record = match read_refund_record(&path).await { @@ -163,15 +334,28 @@ impl WalletRefundOutbox { Err(error) => return Err(error), }; - match self - .spacetime_client - .refund_profile_wallet_points_with_metadata( + let enqueue_input = + module_runtime::build_runtime_profile_wallet_refund_outbox_enqueue_input( record.owner_user_id.clone(), record.amount, record.ledger_id.clone(), record.created_at_micros, - refund_metadata_json(record.external_generation_job_id.as_deref()), + record.asset_kind.clone(), + record.asset_id.clone(), + record.settlement_reason.clone(), + record.external_generation_job_id.clone(), + record + .external_generation_claim_attempt + .or_else(|| infer_external_generation_claim_attempt(&record)), ) + .map_err(|error| { + WalletRefundOutboxError::Spacetime(SpacetimeClientError::Runtime( + error.to_string(), + )) + })?; + match self + .spacetime_client + .enqueue_profile_wallet_refund_outbox(enqueue_input) .await { Ok(_) => { @@ -188,7 +372,7 @@ impl WalletRefundOutbox { asset_id = %record.asset_id, external_generation_job_id = ?record.external_generation_job_id, path = %path.display(), - "wallet refund outbox 退款已重放并删除文件" + "wallet refund emergency spool 已恢复到 SpacetimeDB outbox 并删除文件" ); } Err(error) => return Err(WalletRefundOutboxError::Spacetime(error)), @@ -197,6 +381,159 @@ impl WalletRefundOutbox { Ok(()) } + async fn recover_temporary_files(&self) -> Result<(), WalletRefundOutboxError> { + let _guard = self.enqueue_lock.lock().await; + let temporary_files = self.list_temporary_files().await?; + 'temporary_files: for path in temporary_files { + let record = match read_refund_record(&path).await { + Ok(record) => record, + Err(error) if error.is_data_corruption() => { + self.quarantine_file(&path).await?; + warn!( + error = %error, + source = %path.display(), + "wallet refund outbox 崩溃遗留临时文件无法解析,已隔离" + ); + continue; + } + Err(error) => return Err(error), + }; + + let pending_path = self.pending_path_for_ledger(&record.ledger_id); + let overflow_path = self.overflow_path_for_ledger(&record.ledger_id); + for existing_path in [&pending_path, &overflow_path] { + match self + .reuse_existing_pending_file(existing_path, &record) + .await + { + Ok(true) => { + remove_file_and_sync(&path, &self.dir).await?; + debug!( + ledger_id = %record.ledger_id, + source = %path.display(), + target = %existing_path.display(), + "wallet refund outbox 临时文件与已有幂等文件重复,已删除临时副本" + ); + continue 'temporary_files; + } + Ok(false) => {} + Err(WalletRefundOutboxError::Io(error)) + if error.kind() == std::io::ErrorKind::AlreadyExists => + { + self.quarantine_file(&path).await?; + warn!( + ledger_id = %record.ledger_id, + source = %path.display(), + target = %existing_path.display(), + error = %error, + "wallet refund outbox 临时文件与现有幂等文件事实冲突,已隔离临时文件" + ); + continue 'temporary_files; + } + Err(error) => return Err(error), + } + } + + let temp_bytes = fs::metadata(&path).await?.len(); + let record_bytes = serde_json::to_vec(&record)?; + let current_bytes = directory_size_if_exists(&self.dir) + .unwrap_or(0) + .saturating_sub(temp_bytes); + let target_path = + if current_bytes.saturating_add(record_bytes.len() as u64) > self.max_bytes { + &overflow_path + } else { + &pending_path + }; + + match fs::hard_link(&path, target_path).await { + Ok(()) => { + sync_directory_metadata(&self.dir).await?; + remove_file_and_sync(&path, &self.dir).await?; + debug!( + ledger_id = %record.ledger_id, + source = %path.display(), + target = %target_path.display(), + "wallet refund outbox 崩溃遗留临时文件已恢复为幂等退款文件" + ); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + match self.reuse_existing_pending_file(target_path, &record).await { + Ok(true) => { + // Another writer won the same ledger id with identical facts. Keep + // the first durable file and remove only this duplicate temporary + // link. + remove_file_and_sync(&path, &self.dir).await?; + } + Ok(false) => match fs::hard_link(&path, target_path).await { + Ok(()) => { + sync_directory_metadata(&self.dir).await?; + remove_file_and_sync(&path, &self.dir).await?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // The file may have been completed by another process after the + // scan. + continue; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + self.quarantine_file(&path).await?; + warn!( + ledger_id = %record.ledger_id, + source = %path.display(), + target = %target_path.display(), + "wallet refund outbox 临时文件无法与现有幂等文件合并,已隔离临时文件" + ); + } + Err(error) => return Err(error.into()), + }, + Err(WalletRefundOutboxError::Io(error)) + if error.kind() == std::io::ErrorKind::AlreadyExists => + { + self.quarantine_file(&path).await?; + warn!( + ledger_id = %record.ledger_id, + source = %path.display(), + target = %target_path.display(), + error = %error, + "wallet refund outbox 临时文件与现有幂等文件事实冲突,已隔离临时文件" + ); + } + Err(error) => return Err(error), + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // The file may have been completed by another process after the scan. + continue; + } + Err(error) => return Err(error.into()), + } + } + Ok(()) + } + + async fn list_temporary_files(&self) -> Result, WalletRefundOutboxError> { + let mut entries = fs::read_dir(&self.dir).await?; + let mut files = Vec::new(); + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if name.starts_with(TEMP_FILE_PREFIX) && name.ends_with(OUTBOX_FILE_EXTENSION) { + files.push(path); + } + } + files.sort(); + Ok(files) + } + + async fn quarantine_file(&self, path: &Path) -> Result<(), WalletRefundOutboxError> { + let corrupt_path = self.corrupt_path_for(path); + fs::rename(path, &corrupt_path).await?; + sync_directory_metadata(&self.dir).await?; + Ok(()) + } + async fn list_pending_files(&self) -> Result, WalletRefundOutboxError> { let mut entries = fs::read_dir(&self.dir).await?; let mut files = Vec::new(); @@ -205,7 +542,9 @@ impl WalletRefundOutbox { let Some(name) = path.file_name().and_then(|value| value.to_str()) else { continue; }; - if name.starts_with(PENDING_FILE_PREFIX) && name.ends_with(OUTBOX_FILE_EXTENSION) { + if (name.starts_with(PENDING_FILE_PREFIX) || name.starts_with(OVERFLOW_FILE_PREFIX)) + && name.ends_with(OUTBOX_FILE_EXTENSION) + { files.push(path); } } @@ -220,6 +559,13 @@ impl WalletRefundOutbox { )) } + fn overflow_path_for_ledger(&self, ledger_id: &str) -> PathBuf { + self.dir.join(format!( + "{OVERFLOW_FILE_PREFIX}{}{OUTBOX_FILE_EXTENSION}", + ledger_id_hash(ledger_id) + )) + } + fn temp_path(&self) -> PathBuf { self.dir.join(format!( "{TEMP_FILE_PREFIX}{}-{uuid}{OUTBOX_FILE_EXTENSION}", @@ -241,18 +587,17 @@ impl WalletRefundOutbox { } } -fn refund_metadata_json(external_generation_job_id: Option<&str>) -> String { - let Some(external_generation_job_id) = external_generation_job_id - .map(str::trim) - .filter(|value| !value.is_empty()) - else { - return module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(); - }; +fn default_settlement_reason() -> String { + "emergency_spool_replay".to_string() +} - serde_json::json!({ - "externalGenerationJobId": external_generation_job_id, - }) - .to_string() +fn infer_external_generation_claim_attempt(record: &WalletRefundOutboxRecord) -> Option { + let job_id = record.external_generation_job_id.as_deref()?.trim(); + let prefix = format!("asset_operation_refund:external_generation_job:{job_id}:attempt:"); + record + .ledger_id + .strip_prefix(&prefix) + .and_then(|value| value.parse::().ok()) } impl fmt::Debug for WalletRefundOutbox { @@ -311,7 +656,7 @@ fn directory_size_if_exists(path: &Path) -> Result { let mut total = 0u64; for entry in std::fs::read_dir(path)? { let entry = entry?; - if !is_pending_outbox_file_name(&entry.file_name()) { + if !is_capped_outbox_file_name(&entry.file_name()) { continue; } let metadata = entry.metadata()?; @@ -335,10 +680,29 @@ fn ledger_id_hash(ledger_id: &str) -> String { fn is_pending_outbox_file_name(name: &std::ffi::OsStr) -> bool { name.to_str().is_some_and(|value| { - value.starts_with(PENDING_FILE_PREFIX) && value.ends_with(OUTBOX_FILE_EXTENSION) + (value.starts_with(PENDING_FILE_PREFIX) + || value.starts_with(OVERFLOW_FILE_PREFIX) + || value.starts_with(TEMP_FILE_PREFIX)) + && value.ends_with(OUTBOX_FILE_EXTENSION) }) } +fn is_capped_outbox_file_name(name: &std::ffi::OsStr) -> bool { + name.to_str().is_some_and(|value| { + ((value.starts_with(PENDING_FILE_PREFIX) && !value.starts_with(OVERFLOW_FILE_PREFIX)) + || value.starts_with(TEMP_FILE_PREFIX)) + && value.ends_with(OUTBOX_FILE_EXTENSION) + }) +} + +async fn remove_file_and_sync(path: &Path, dir: &Path) -> Result<(), WalletRefundOutboxError> { + match fs::remove_file(path).await { + Ok(()) => sync_directory_metadata(dir).await, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + async fn sync_directory_metadata(path: &Path) -> Result<(), WalletRefundOutboxError> { let path = path.to_path_buf(); tokio::task::spawn_blocking(move || { @@ -362,7 +726,9 @@ mod tests { created_at_micros: 1_713_680_000_000_000, asset_kind: "puzzle_initial_image".to_string(), asset_id: "asset-1".to_string(), + settlement_reason: "worker_attempt_failed".to_string(), external_generation_job_id: Some("extgen-test".to_string()), + external_generation_claim_attempt: Some(1), } } @@ -416,7 +782,35 @@ mod tests { } #[tokio::test] - async fn enqueue_drops_when_outbox_exceeds_max_bytes() { + async fn enqueue_rejects_conflicting_existing_ledger_file() { + let dir = test_dir("conflicting-ledger"); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + outbox.enqueue(sample_record("ledger-1")).await.unwrap(); + + let mut conflicting = sample_record("ledger-1"); + conflicting.amount += 1; + let error = outbox + .enqueue(conflicting) + .await + .expect_err("conflicting refund must fail"); + assert!( + matches!(error, WalletRefundOutboxError::Io(error) if error.kind() == std::io::ErrorKind::AlreadyExists) + ); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn legacy_external_refund_record_infers_missing_claim_attempt() { + let mut record = + sample_record("asset_operation_refund:external_generation_job:extgen-test:attempt:7"); + record.external_generation_claim_attempt = None; + + assert_eq!(infer_external_generation_claim_attempt(&record), Some(7)); + } + + #[tokio::test] + async fn enqueue_uses_durable_overflow_file_when_outbox_exceeds_max_bytes() { let dir = test_dir("max-bytes"); let outbox = test_outbox(dir.clone(), 1); @@ -424,11 +818,18 @@ mod tests { assert!(matches!( outcome, - WalletRefundOutboxEnqueueOutcome::Dropped { + WalletRefundOutboxEnqueueOutcome::OverflowEnqueued { reason: "max_bytes" } )); - assert!(!dir.exists() || std::fs::read_dir(&dir).unwrap().next().is_none()); + assert!(outbox.overflow_path_for_ledger("ledger-1").is_file()); + assert_eq!(directory_size_if_exists(&dir).unwrap(), 0); + assert_eq!( + read_refund_record(&outbox.overflow_path_for_ledger("ledger-1")) + .await + .unwrap(), + sample_record("ledger-1") + ); let _ = std::fs::remove_dir_all(dir); } @@ -459,6 +860,36 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn enqueue_does_not_silently_accept_corrupt_pending_file() { + let dir = test_dir("corrupt-pending-enqueue"); + std::fs::create_dir_all(&dir).unwrap(); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + let record = sample_record("ledger-corrupt-pending"); + let pending_path = outbox.pending_path_for_ledger(&record.ledger_id); + std::fs::write(&pending_path, b"{not-json}").unwrap(); + + outbox.enqueue(record.clone()).await.unwrap(); + + assert_eq!( + read_refund_record(&pending_path).await.unwrap().ledger_id, + record.ledger_id + ); + let corrupt_count = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(CORRUPT_FILE_PREFIX)) + }) + .count(); + assert_eq!(corrupt_count, 1); + + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn shutdown_flush_keeps_file_when_spacetime_is_unavailable() { let dir = test_dir("shutdown"); @@ -480,4 +911,132 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + + #[tokio::test] + async fn flush_recovers_valid_crash_left_temp_file() { + let dir = test_dir("recover-temp"); + std::fs::create_dir_all(&dir).unwrap(); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + let record = sample_record("ledger-temp"); + let temp_path = outbox.temp_path(); + std::fs::write(&temp_path, serde_json::to_vec(&record).unwrap()).unwrap(); + + let result = outbox.flush_pending_files_once().await; + + assert!(matches!(result, Err(WalletRefundOutboxError::Spacetime(_)))); + assert!(!temp_path.exists()); + assert!(outbox.pending_path_for_ledger(&record.ledger_id).exists()); + + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn flush_recovers_crash_left_temp_file_into_overflow_when_capped() { + let dir = test_dir("recover-temp-overflow"); + std::fs::create_dir_all(&dir).unwrap(); + let outbox = test_outbox(dir.clone(), 1); + let existing = sample_record("ledger-existing"); + std::fs::write( + outbox.pending_path_for_ledger(&existing.ledger_id), + serde_json::to_vec(&existing).unwrap(), + ) + .unwrap(); + let record = sample_record("ledger-temp-overflow"); + let temp_path = outbox.temp_path(); + std::fs::write(&temp_path, serde_json::to_vec(&record).unwrap()).unwrap(); + + let result = outbox.flush_pending_files_once().await; + + assert!(matches!(result, Err(WalletRefundOutboxError::Spacetime(_)))); + assert!(!temp_path.exists()); + assert!(outbox.overflow_path_for_ledger(&record.ledger_id).exists()); + + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn flush_quarantines_conflicting_crash_left_temp_file() { + let dir = test_dir("recover-conflicting-temp"); + std::fs::create_dir_all(&dir).unwrap(); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + let record = sample_record("ledger-temp-conflict"); + let mut conflicting = record.clone(); + conflicting.amount += 1; + let pending_path = outbox.pending_path_for_ledger(&record.ledger_id); + let temp_path = outbox.temp_path(); + std::fs::write(&pending_path, serde_json::to_vec(&conflicting).unwrap()).unwrap(); + std::fs::write(&temp_path, serde_json::to_vec(&record).unwrap()).unwrap(); + + let result = outbox.flush_pending_files_once().await; + + assert!(matches!(result, Err(WalletRefundOutboxError::Spacetime(_)))); + assert!(!temp_path.exists()); + assert_eq!( + read_refund_record(&pending_path).await.unwrap(), + conflicting + ); + let corrupt_count = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(CORRUPT_FILE_PREFIX)) + }) + .count(); + assert_eq!(corrupt_count, 1); + + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn flush_quarantines_corrupt_crash_left_temp_file() { + let dir = test_dir("recover-corrupt-temp"); + std::fs::create_dir_all(&dir).unwrap(); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + let temp_path = outbox.temp_path(); + std::fs::write(&temp_path, b"{not-json}").unwrap(); + + outbox.flush_pending_files_once().await.unwrap(); + + assert!(!temp_path.exists()); + let corrupt_count = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(CORRUPT_FILE_PREFIX)) + }) + .count(); + assert_eq!(corrupt_count, 1); + + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn worker_recovers_temp_file_immediately_on_startup() { + let dir = test_dir("worker-startup"); + std::fs::create_dir_all(&dir).unwrap(); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + let record = sample_record("ledger-worker-startup"); + let temp_path = outbox.temp_path(); + std::fs::write(&temp_path, serde_json::to_vec(&record).unwrap()).unwrap(); + + outbox.clone().spawn_worker(); + + for _ in 0..100 { + if !temp_path.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + assert!(!temp_path.exists()); + assert!(outbox.pending_path_for_ledger(&record.ledger_id).exists()); + + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/server-rs/crates/api-server/src/wechat/auth.rs b/server-rs/crates/api-server/src/wechat/auth.rs index 420438146..5f553f51f 100644 --- a/server-rs/crates/api-server/src/wechat/auth.rs +++ b/server-rs/crates/api-server/src/wechat/auth.rs @@ -16,6 +16,7 @@ use shared_contracts::auth::{ }; use shared_kernel::normalize_optional_string; use time::OffsetDateTime; +use tracing::warn; use url::Url; use crate::{ @@ -42,6 +43,13 @@ pub async fn start_wechat_login( if !state.config.wechat_auth_enabled { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")); } + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新微信认证状态失败:{error}")) + })?; let user_agent = headers .get("user-agent") .and_then(|value| value.to_str().ok()) @@ -62,6 +70,13 @@ pub async fn start_wechat_login( OffsetDateTime::now_utc(), ) .map_err(map_wechat_auth_error)?; + state + .sync_auth_store_tables_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步微信登录状态失败:{error}")) + })?; let authorization_url = state .wechat_provider() .build_authorization_url( @@ -107,6 +122,13 @@ pub async fn start_wechat_bind( OffsetDateTime::now_utc(), ) .map_err(map_wechat_auth_error)?; + state + .sync_auth_store_tables_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步微信绑定状态失败:{error}")) + })?; let authorization_url = state .wechat_provider() .build_authorization_url( @@ -149,11 +171,59 @@ pub async fn handle_wechat_callback( .into_response()); } - let consumed = match state + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + warn!( + request_id = request_context.request_id(), + operation = request_context.operation(), + error = %error, + "微信回调前刷新认证投影失败" + ); + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message("刷新微信认证状态失败") + })?; + + let consume_result = state .wechat_auth_state_service() - .consume_state(&state_token, OffsetDateTime::now_utc()) - { + .consume_state(&state_token, OffsetDateTime::now_utc()); + let consumed = match consume_result { Ok(value) => value, + Err(WechatAuthError::StateNotFound) => { + if let Err(error) = state.refresh_auth_store_from_spacetime().await { + warn!( + request_id = request_context.request_id(), + operation = request_context.operation(), + error = %error, + "微信 state 未命中后的认证投影刷新失败" + ); + return Ok(Redirect::to(&build_auth_result_redirect_url( + &fallback_redirect, + &[ + ("auth_provider", "wechat"), + ("auth_error", "微信登录状态已失效,请重新发起登录。"), + ], + )) + .into_response()); + } + match state + .wechat_auth_state_service() + .consume_state(&state_token, OffsetDateTime::now_utc()) + { + Ok(value) => value, + Err(_) => { + return Ok(Redirect::to(&build_auth_result_redirect_url( + &fallback_redirect, + &[ + ("auth_provider", "wechat"), + ("auth_error", "微信登录状态已失效,请重新发起登录。"), + ], + )) + .into_response()); + } + } + } Err(_) => { return Ok(Redirect::to(&build_auth_result_redirect_url( &fallback_redirect, @@ -165,6 +235,22 @@ pub async fn handle_wechat_callback( .into_response()); } }; + if let Err(error) = state.sync_auth_store_tables_to_spacetime().await { + warn!( + request_id = request_context.request_id(), + operation = request_context.operation(), + error = %error, + "微信回调消费 state 后同步失败" + ); + return Ok(Redirect::to(&build_auth_result_redirect_url( + &fallback_redirect, + &[ + ("auth_provider", "wechat"), + ("auth_error", "微信登录服务暂时不可用,请稍后重试。"), + ], + )) + .into_response()); + } let redirect_path = consumed.state.redirect_path.clone(); let session_client = resolve_session_client_context(&headers); @@ -293,7 +379,7 @@ pub async fn bind_wechat_phone( .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少短信验证码") })?; - state + match state .phone_auth_service() .bind_wechat_phone( BindWechatPhoneInput { @@ -306,7 +392,17 @@ pub async fn bind_wechat_phone( OffsetDateTime::now_utc(), ) .await - .map_err(map_wechat_bind_phone_error)? + { + Ok(result) => result, + Err(error) => { + if let Err(sync_error) = state.sync_auth_store_tables_to_spacetime().await { + warn!(error = %sync_error, "微信绑定手机号失败后的短信验证码状态同步失败"); + return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message("同步短信验证码状态失败")); + } + return Err(map_wechat_bind_phone_error(error)); + } + } }; let session_client = resolve_session_client_context(&headers); let signed_session = create_auth_session( @@ -365,6 +461,13 @@ pub async fn login_wechat_mini_program( if !state.config.wechat_auth_enabled { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")); } + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新微信认证状态失败:{error}")) + })?; let code = payload.code.trim(); if code.is_empty() { return Err( diff --git a/server-rs/crates/api-server/src/wechat/pay.rs b/server-rs/crates/api-server/src/wechat/pay.rs index 556c8f00e..3cc839884 100644 --- a/server-rs/crates/api-server/src/wechat/pay.rs +++ b/server-rs/crates/api-server/src/wechat/pay.rs @@ -239,6 +239,10 @@ async fn confirm_virtual_payment_recharge_order( )); } + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| WechatPayError::Upstream(format!("刷新认证状态失败:{error}")))?; let identity = state .wechat_auth_service() .get_identity_by_user_id(&order.user_id) diff --git a/server-rs/crates/api-server/src/wechat/subscribe_message.rs b/server-rs/crates/api-server/src/wechat/subscribe_message.rs index 8946afaf1..863c0185b 100644 --- a/server-rs/crates/api-server/src/wechat/subscribe_message.rs +++ b/server-rs/crates/api-server/src/wechat/subscribe_message.rs @@ -56,6 +56,13 @@ async fn send_generation_result_subscribe_message( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("微信订阅消息模板 ID 未配置") })?; + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新微信认证状态失败:{error}")) + })?; let user = state .auth_user_service() .get_user_by_id(&message.owner_user_id) diff --git a/server-rs/crates/module-ai/src/application/service.rs b/server-rs/crates/module-ai/src/application/service.rs index 713f3cd33..6f42f1d69 100644 --- a/server-rs/crates/module-ai/src/application/service.rs +++ b/server-rs/crates/module-ai/src/application/service.rs @@ -28,7 +28,7 @@ impl AiTaskService { validate_task_create_input(&input).map_err(AiTaskServiceError::Field)?; let snapshot = AiTaskSnapshot { - task_id: input.task_id.clone(), + task_id: normalize_required_string(input.task_id).unwrap_or_default(), task_kind: input.task_kind, owner_user_id: normalize_required_string(input.owner_user_id).unwrap_or_default(), request_label: normalize_required_string(input.request_label).unwrap_or_default(), diff --git a/server-rs/crates/module-ai/src/application/store.rs b/server-rs/crates/module-ai/src/application/store.rs index f2d0c1175..35a9725a9 100644 --- a/server-rs/crates/module-ai/src/application/store.rs +++ b/server-rs/crates/module-ai/src/application/store.rs @@ -1,10 +1,12 @@ use std::{ - collections::HashMap, + collections::{BTreeMap, HashMap}, sync::{Arc, Mutex}, }; use crate::{ AiTaskServiceError, AiTaskSnapshot, AiTaskStageStatus, AiTaskStatus, AiTextChunkSnapshot, + MAX_AI_TASK_RETAINED_OUTPUT_BYTES, MAX_AI_TASK_RETAINED_TASKS, MAX_AI_TASK_TEXT_OUTPUT_BYTES, + validate_ai_task_snapshot_memory_limits, }; use super::ensure_task_is_not_terminal; @@ -17,7 +19,9 @@ pub struct InMemoryAiTaskStore { #[derive(Debug, Default)] struct InMemoryAiTaskStoreState { tasks: HashMap, - text_chunks: HashMap>, + // Keep only the ordered deltas needed to handle an out-of-order chunk. + // Completed tasks drop this map immediately; it is not a second durable log. + text_chunks: HashMap>>, } impl InMemoryAiTaskStore { @@ -34,7 +38,48 @@ impl InMemoryAiTaskStore { return Err(AiTaskServiceError::TaskAlreadyExists); } - state.text_chunks.insert(task.task_id.clone(), Vec::new()); + validate_task_memory_limits(&task)?; + + let oldest_terminal = if state.tasks.len() >= MAX_AI_TASK_RETAINED_TASKS { + let oldest_terminal = state + .tasks + .values() + .filter(|value| value.status.is_terminal()) + .min_by_key(|value| value.completed_at_micros.or(Some(value.updated_at_micros))) + .map(|value| value.task_id.clone()); + if oldest_terminal.is_none() { + return Err(AiTaskServiceError::Store( + "AI 任务仓储已达到内存容量上限".to_string(), + )); + } + oldest_terminal + } else { + None + }; + + let retained_output_bytes = retained_output_bytes(&state) + .saturating_sub( + oldest_terminal + .as_deref() + .and_then(|task_id| state.tasks.get(task_id)) + .map(task_output_bytes) + .unwrap_or_default(), + ) + .saturating_add(task_output_bytes(&task)); + if retained_output_bytes > MAX_AI_TASK_RETAINED_OUTPUT_BYTES { + return Err(AiTaskServiceError::Store( + "AI 任务仓储输出工作集超过内存上限".to_string(), + )); + } + + if let Some(task_id) = oldest_terminal { + state.tasks.remove(&task_id); + state.text_chunks.remove(&task_id); + } + + state + .text_chunks + .insert(task.task_id.clone(), HashMap::new()); state.tasks.insert(task.task_id.clone(), task.clone()); Ok(task) } @@ -51,12 +96,44 @@ impl InMemoryAiTaskStore { .inner .lock() .map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?; - let task = state - .tasks - .get_mut(task_id.trim()) - .ok_or(AiTaskServiceError::TaskNotFound)?; - apply(task)?; - Ok(task.clone()) + let (previous_task, snapshot) = { + let task = state + .tasks + .get_mut(task_id.trim()) + .ok_or(AiTaskServiceError::TaskNotFound)?; + let previous_task = task.clone(); + if let Err(error) = apply(task) { + *task = previous_task; + return Err(error); + } + (previous_task, task.clone()) + }; + if let Err(error) = validate_task_memory_limits(&snapshot) { + state + .tasks + .insert(task_id.trim().to_string(), previous_task); + return Err(error); + } + let released_text_chunks = if snapshot.status.is_terminal() { + state.text_chunks.remove(task_id.trim()) + } else { + None + }; + let retained_output_bytes = retained_output_bytes(&state); + if retained_output_bytes > MAX_AI_TASK_RETAINED_OUTPUT_BYTES { + state + .tasks + .insert(task_id.trim().to_string(), previous_task); + if let Some(text_chunks) = released_text_chunks { + state + .text_chunks + .insert(task_id.trim().to_string(), text_chunks); + } + return Err(AiTaskServiceError::Store( + "AI 任务仓储输出工作集超过内存上限".to_string(), + )); + } + Ok(snapshot) } pub(super) fn append_text_chunk( @@ -67,13 +144,82 @@ impl InMemoryAiTaskStore { .inner .lock() .map_err(|_| AiTaskServiceError::Store("AI 任务仓储锁已中毒".to_string()))?; - { + if chunk.delta_text.len() > MAX_AI_TASK_TEXT_OUTPUT_BYTES { + return Err(AiTaskServiceError::Store( + "AI 任务文本输出超过内存上限".to_string(), + )); + } + let (previous_stage_output_bytes, previous_latest_output_bytes, previous_task) = { + let task = state + .tasks + .get(&chunk.task_id) + .ok_or(AiTaskServiceError::TaskNotFound)?; + ensure_task_is_not_terminal(task.status)?; + let stage = task + .stages + .iter() + .find(|stage| stage.stage_kind == chunk.stage_kind) + .ok_or(AiTaskServiceError::StageNotFound)?; + ( + stage.text_output.as_ref().map_or(0, String::len), + task.latest_text_output.as_ref().map_or(0, String::len), + task.clone(), + ) + }; + + let (previous_chunk, aggregated_bytes, aggregated_text) = { + let chunks = state + .text_chunks + .get_mut(&chunk.task_id) + .ok_or(AiTaskServiceError::TaskNotFound)?; + let stage_chunks = chunks.entry(chunk.stage_kind).or_default(); + if !stage_chunks.contains_key(&chunk.sequence) + && stage_chunks.len() >= crate::MAX_AI_TASK_TEXT_CHUNKS_PER_STAGE + { + return Err(AiTaskServiceError::Store( + "AI 任务文本 chunk 数量超过内存上限".to_string(), + )); + } + let previous_chunk = stage_chunks.insert(chunk.sequence, chunk.delta_text.clone()); + let aggregated_bytes = stage_chunks + .values() + .fold(0_usize, |total, delta| total.saturating_add(delta.len())); + let mut aggregated_text = String::with_capacity(aggregated_bytes); + for delta in stage_chunks.values() { + aggregated_text.push_str(delta); + } + (previous_chunk, aggregated_bytes, aggregated_text) + }; + if aggregated_bytes > MAX_AI_TASK_TEXT_OUTPUT_BYTES { + rollback_text_chunk(&mut state, &chunk, previous_chunk); + return Err(AiTaskServiceError::Store( + "AI 任务文本输出超过内存上限".to_string(), + )); + } + + let projected_retained_output_bytes = retained_output_bytes(&state) + .saturating_sub(previous_stage_output_bytes) + .saturating_sub(previous_latest_output_bytes) + .saturating_add(aggregated_bytes.saturating_mul(2)); + if projected_retained_output_bytes > MAX_AI_TASK_RETAINED_OUTPUT_BYTES { + rollback_text_chunk(&mut state, &chunk, previous_chunk); + return Err(AiTaskServiceError::Store( + "AI 任务仓储输出工作集超过内存上限".to_string(), + )); + } + + let normalized_output = if aggregated_text.trim().is_empty() { + None + } else { + Some(aggregated_text) + }; + + let snapshot = { let task = state .tasks .get_mut(&chunk.task_id) .ok_or(AiTaskServiceError::TaskNotFound)?; ensure_task_is_not_terminal(task.status)?; - let stage = task .stages .iter_mut() @@ -83,45 +229,23 @@ impl InMemoryAiTaskStore { stage.status = AiTaskStageStatus::Running; stage.started_at_micros = Some(chunk.created_at_micros); } - task.status = AiTaskStatus::Running; task.started_at_micros .get_or_insert(chunk.created_at_micros); - } - - let chunks = state - .text_chunks - .get_mut(&chunk.task_id) - .ok_or(AiTaskServiceError::TaskNotFound)?; - chunks.push(chunk.clone()); - chunks.sort_by_key(|value| value.sequence); - - let aggregated_text = chunks - .iter() - .filter(|value| value.stage_kind == chunk.stage_kind) - .map(|value| value.delta_text.as_str()) - .collect::>() - .join(""); - let normalized_output = if aggregated_text.trim().is_empty() { - None - } else { - Some(aggregated_text) + stage.text_output = normalized_output.clone(); + task.latest_text_output = normalized_output; + task.updated_at_micros = chunk.created_at_micros; + task.version += 1; + task.clone() }; - - let task = state - .tasks - .get_mut(&chunk.task_id) - .ok_or(AiTaskServiceError::TaskNotFound)?; - let stage = task - .stages - .iter_mut() - .find(|stage| stage.stage_kind == chunk.stage_kind) - .ok_or(AiTaskServiceError::StageNotFound)?; - stage.text_output = normalized_output.clone(); - task.latest_text_output = normalized_output; - task.updated_at_micros = chunk.created_at_micros; - task.version += 1; - Ok(task.clone()) + if let Err(error) = validate_task_memory_limits(&snapshot) + .and_then(|_| validate_retained_output_bytes(&state)) + { + state.tasks.insert(chunk.task_id.clone(), previous_task); + rollback_text_chunk(&mut state, &chunk, previous_chunk); + return Err(error); + } + Ok(snapshot) } pub(super) fn get_task(&self, task_id: &str) -> Result { @@ -136,3 +260,246 @@ impl InMemoryAiTaskStore { .ok_or(AiTaskServiceError::TaskNotFound) } } + +fn rollback_text_chunk( + state: &mut InMemoryAiTaskStoreState, + chunk: &AiTextChunkSnapshot, + previous_chunk: Option, +) { + if let Some(stage_chunks) = state + .text_chunks + .get_mut(&chunk.task_id) + .and_then(|chunks| chunks.get_mut(&chunk.stage_kind)) + { + if let Some(previous_chunk) = previous_chunk { + stage_chunks.insert(chunk.sequence, previous_chunk); + } else { + stage_chunks.remove(&chunk.sequence); + } + } +} + +fn retained_output_bytes(state: &InMemoryAiTaskStoreState) -> usize { + let snapshot_bytes = state.tasks.values().fold(0_usize, |total, task| { + total.saturating_add(task_output_bytes(task)) + }); + state + .text_chunks + .values() + .fold(snapshot_bytes, |total, stages| { + stages.values().fold(total, |stage_total, chunks| { + chunks.values().fold(stage_total, |chunk_total, delta| { + chunk_total.saturating_add(delta.len()) + }) + }) + }) +} + +fn validate_retained_output_bytes( + state: &InMemoryAiTaskStoreState, +) -> Result<(), AiTaskServiceError> { + if retained_output_bytes(state) > MAX_AI_TASK_RETAINED_OUTPUT_BYTES { + return Err(AiTaskServiceError::Store( + "AI 任务仓储输出工作集超过内存上限".to_string(), + )); + } + Ok(()) +} + +fn task_output_bytes(task: &AiTaskSnapshot) -> usize { + let task_metadata = task + .task_id + .len() + .saturating_add(task.owner_user_id.len()) + .saturating_add(task.request_label.len()) + .saturating_add(task.source_module.len()) + .saturating_add(task.source_entity_id.as_ref().map_or(0, String::len)) + .saturating_add(task.stages.iter().fold(0_usize, |total, stage| { + total + .saturating_add(stage.label.len()) + .saturating_add(stage.detail.len()) + })); + let request_payload = task.request_payload_json.as_ref().map_or(0, String::len); + let failure_message = task.failure_message.as_ref().map_or(0, String::len); + let result_references = task + .result_references + .iter() + .fold(0_usize, |total, reference| { + total + .saturating_add(reference.result_ref_id.len()) + .saturating_add(reference.task_id.len()) + .saturating_add(reference.reference_id.len()) + .saturating_add(reference.label.as_ref().map_or(0, String::len)) + }); + let latest_text = task.latest_text_output.as_ref().map_or(0, String::len); + let latest_structured = task + .latest_structured_payload_json + .as_ref() + .map_or(0, String::len); + let stage_bytes = task.stages.iter().fold(0_usize, |total, stage| { + let text = stage.text_output.as_ref().map_or(0, String::len); + let structured = stage + .structured_payload_json + .as_ref() + .map_or(0, String::len); + let warnings = stage + .warning_messages + .iter() + .fold(0_usize, |warning_total, warning| { + warning_total.saturating_add(warning.len()) + }); + total + .saturating_add(text) + .saturating_add(structured) + .saturating_add(warnings) + }); + task_metadata + .saturating_add(request_payload) + .saturating_add(failure_message) + .saturating_add(result_references) + .saturating_add(latest_text) + .saturating_add(latest_structured) + .saturating_add(stage_bytes) +} + +fn validate_task_memory_limits(task: &AiTaskSnapshot) -> Result<(), AiTaskServiceError> { + validate_ai_task_snapshot_memory_limits(task) + .map_err(|message| AiTaskServiceError::Store(message.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn build_running_task(task_id: &str) -> AiTaskSnapshot { + AiTaskSnapshot { + task_id: task_id.to_string(), + task_kind: crate::AiTaskKind::CharacterChat, + owner_user_id: "user-1".to_string(), + request_label: "测试任务".to_string(), + source_module: "test".to_string(), + source_entity_id: None, + request_payload_json: None, + status: AiTaskStatus::Running, + failure_message: None, + stages: vec![crate::AiTaskStageSnapshot { + stage_kind: crate::AiTaskStageKind::RequestModel, + label: "请求模型".to_string(), + detail: "测试".to_string(), + order: 0, + status: AiTaskStageStatus::Running, + text_output: None, + structured_payload_json: None, + warning_messages: Vec::new(), + started_at_micros: Some(1), + completed_at_micros: None, + }], + result_references: Vec::new(), + latest_text_output: None, + latest_structured_payload_json: None, + version: 1, + created_at_micros: 1, + started_at_micros: Some(1), + completed_at_micros: None, + updated_at_micros: 1, + } + } + + #[test] + fn append_text_chunk_rejects_excessive_chunk_count() { + let store = InMemoryAiTaskStore::default(); + let task_id = "task-chunk-limit"; + let task = build_running_task(task_id); + let mut state = store.inner.lock().expect("store lock should be available"); + state.tasks.insert(task_id.to_string(), task); + state.text_chunks.insert( + task_id.to_string(), + HashMap::from([( + crate::AiTaskStageKind::RequestModel, + (1..=crate::MAX_AI_TASK_TEXT_CHUNKS_PER_STAGE as u32) + .map(|sequence| (sequence, "a".to_string())) + .collect(), + )]), + ); + drop(state); + + let error = store + .append_text_chunk(AiTextChunkSnapshot { + chunk_id: "chunk-overflow".to_string(), + task_id: task_id.to_string(), + stage_kind: crate::AiTaskStageKind::RequestModel, + sequence: crate::MAX_AI_TASK_TEXT_CHUNKS_PER_STAGE as u32 + 1, + delta_text: "b".to_string(), + created_at_micros: 2, + }) + .expect_err("a new chunk beyond the count cap should fail"); + assert!(matches!( + error, + AiTaskServiceError::Store(message) if message.contains("chunk 数量") + )); + } + + #[test] + fn terminal_failure_releases_chunks_before_global_cap_check() { + let store = InMemoryAiTaskStore::default(); + let target_id = "task-terminal-release"; + let target = build_running_task(target_id); + let mut state = store.inner.lock().expect("store lock should be available"); + state.tasks.insert(target_id.to_string(), target); + state.text_chunks.insert( + target_id.to_string(), + HashMap::from([( + crate::AiTaskStageKind::RequestModel, + BTreeMap::from([(1, "t".repeat(crate::MAX_AI_TASK_TEXT_OUTPUT_BYTES))]), + )]), + ); + + let desired_retained = crate::MAX_AI_TASK_RETAINED_OUTPUT_BYTES + .saturating_sub(crate::MAX_AI_TASK_FAILURE_MESSAGE_BYTES) + .saturating_add(crate::MAX_AI_TASK_FAILURE_MESSAGE_BYTES / 2) + .saturating_sub(4 * 1024); + let mut filler_index = 0_u32; + while retained_output_bytes(&state) < desired_retained { + let remaining = desired_retained.saturating_sub(retained_output_bytes(&state)); + let bytes = remaining.min(crate::MAX_AI_TASK_TEXT_OUTPUT_BYTES); + if bytes == 0 { + break; + } + let filler_id = format!("task-filler-{filler_index}"); + filler_index += 1; + state + .tasks + .insert(filler_id.clone(), build_running_task(&filler_id)); + state.text_chunks.insert( + filler_id, + HashMap::from([( + crate::AiTaskStageKind::RequestModel, + BTreeMap::from([(1, "f".repeat(bytes))]), + )]), + ); + } + let retained_before_failure = retained_output_bytes(&state); + assert!(retained_before_failure <= crate::MAX_AI_TASK_RETAINED_OUTPUT_BYTES); + assert!( + retained_before_failure + crate::MAX_AI_TASK_FAILURE_MESSAGE_BYTES + > crate::MAX_AI_TASK_RETAINED_OUTPUT_BYTES + ); + drop(state); + + let failed = store + .update_task(target_id, |task| { + task.status = AiTaskStatus::Failed; + task.failure_message = Some("f".repeat(crate::MAX_AI_TASK_FAILURE_MESSAGE_BYTES)); + task.completed_at_micros = Some(2); + task.updated_at_micros = 2; + task.version += 1; + Ok(()) + }) + .expect("terminal transition should release chunks before checking the cap"); + assert_eq!(failed.status, AiTaskStatus::Failed); + assert_eq!( + failed.failure_message.as_deref().map(str::len), + Some(crate::MAX_AI_TASK_FAILURE_MESSAGE_BYTES) + ); + } +} diff --git a/server-rs/crates/module-ai/src/domain.rs b/server-rs/crates/module-ai/src/domain.rs index a9931048f..abafd7616 100644 --- a/server-rs/crates/module-ai/src/domain.rs +++ b/server-rs/crates/module-ai/src/domain.rs @@ -1,4 +1,5 @@ mod ids; +mod limits; mod stages; mod types; @@ -8,6 +9,17 @@ pub use ids::{ generate_ai_task_stage_id, generate_ai_text_chunk_id, normalize_optional_text, normalize_string_list, }; +pub use limits::{ + MAX_AI_TASK_FAILURE_MESSAGE_BYTES, MAX_AI_TASK_ID_BYTES, MAX_AI_TASK_OWNER_USER_ID_BYTES, + MAX_AI_TASK_REFERENCE_ID_BYTES, MAX_AI_TASK_REFERENCE_LABEL_BYTES, + MAX_AI_TASK_REQUEST_LABEL_BYTES, MAX_AI_TASK_REQUEST_PAYLOAD_BYTES, + MAX_AI_TASK_RESULT_REFERENCES, MAX_AI_TASK_RETAINED_OUTPUT_BYTES, MAX_AI_TASK_RETAINED_TASKS, + MAX_AI_TASK_SOURCE_ENTITY_ID_BYTES, MAX_AI_TASK_SOURCE_MODULE_BYTES, + MAX_AI_TASK_STAGE_DETAIL_BYTES, MAX_AI_TASK_STAGE_LABEL_BYTES, + MAX_AI_TASK_STRUCTURED_OUTPUT_BYTES, MAX_AI_TASK_TEXT_CHUNKS_PER_STAGE, + MAX_AI_TASK_TEXT_OUTPUT_BYTES, MAX_AI_TASK_WARNING_BYTES, + validate_ai_task_snapshot_memory_limits, +}; pub use types::{ AiResultReferenceKind, AiResultReferenceSnapshot, AiTaskKind, AiTaskSnapshot, AiTaskStageBlueprint, AiTaskStageKind, AiTaskStageSnapshot, AiTaskStageStatus, AiTaskStatus, diff --git a/server-rs/crates/module-ai/src/domain/limits.rs b/server-rs/crates/module-ai/src/domain/limits.rs new file mode 100644 index 000000000..16d3a46c2 --- /dev/null +++ b/server-rs/crates/module-ai/src/domain/limits.rs @@ -0,0 +1,117 @@ +use super::types::AiTaskSnapshot; + +pub const MAX_AI_TASK_RETAINED_TASKS: usize = 1024; +pub const MAX_AI_TASK_ID_BYTES: usize = 256; +pub const MAX_AI_TASK_OWNER_USER_ID_BYTES: usize = 256; +pub const MAX_AI_TASK_REQUEST_LABEL_BYTES: usize = 4 * 1024; +pub const MAX_AI_TASK_SOURCE_MODULE_BYTES: usize = 256; +pub const MAX_AI_TASK_SOURCE_ENTITY_ID_BYTES: usize = 512; +pub const MAX_AI_TASK_STAGE_LABEL_BYTES: usize = 4 * 1024; +pub const MAX_AI_TASK_STAGE_DETAIL_BYTES: usize = 8 * 1024; +pub const MAX_AI_TASK_TEXT_OUTPUT_BYTES: usize = 512 * 1024; +// provider 产生大量细小流式增量时,限制行和索引开销。 +pub const MAX_AI_TASK_TEXT_CHUNKS_PER_STAGE: usize = 8 * 1024; +pub const MAX_AI_TASK_STRUCTURED_OUTPUT_BYTES: usize = 512 * 1024; +pub const MAX_AI_TASK_WARNING_BYTES: usize = 64 * 1024; +pub const MAX_AI_TASK_REQUEST_PAYLOAD_BYTES: usize = 512 * 1024; +pub const MAX_AI_TASK_FAILURE_MESSAGE_BYTES: usize = 64 * 1024; +pub const MAX_AI_TASK_RESULT_REFERENCES: usize = 64; +pub const MAX_AI_TASK_REFERENCE_ID_BYTES: usize = 512; +pub const MAX_AI_TASK_REFERENCE_LABEL_BYTES: usize = 2 * 1024; +pub const MAX_AI_TASK_RETAINED_OUTPUT_BYTES: usize = 64 * 1024 * 1024; + +pub fn validate_ai_task_snapshot_memory_limits(task: &AiTaskSnapshot) -> Result<(), &'static str> { + if task.task_id.len() > MAX_AI_TASK_ID_BYTES { + return Err("AI 任务 ID 超过内存上限"); + } + if task.owner_user_id.len() > MAX_AI_TASK_OWNER_USER_ID_BYTES { + return Err("AI 任务用户 ID 超过内存上限"); + } + if task.request_label.len() > MAX_AI_TASK_REQUEST_LABEL_BYTES { + return Err("AI 任务请求标签超过内存上限"); + } + if task.source_module.len() > MAX_AI_TASK_SOURCE_MODULE_BYTES { + return Err("AI 任务来源模块超过内存上限"); + } + if task + .source_entity_id + .as_ref() + .is_some_and(|entity_id| entity_id.len() > MAX_AI_TASK_SOURCE_ENTITY_ID_BYTES) + { + return Err("AI 任务来源实体 ID 超过内存上限"); + } + if task.stages.iter().any(|stage| { + stage.label.len() > MAX_AI_TASK_STAGE_LABEL_BYTES + || stage.detail.len() > MAX_AI_TASK_STAGE_DETAIL_BYTES + }) { + return Err("AI 任务阶段元数据超过内存上限"); + } + if task + .request_payload_json + .as_ref() + .is_some_and(|payload| payload.len() > MAX_AI_TASK_REQUEST_PAYLOAD_BYTES) + { + return Err("AI 任务请求 payload 超过内存上限"); + } + if task + .failure_message + .as_ref() + .is_some_and(|message| message.len() > MAX_AI_TASK_FAILURE_MESSAGE_BYTES) + { + return Err("AI 任务失败消息超过内存上限"); + } + if task.stages.iter().any(|stage| { + stage + .text_output + .as_ref() + .is_some_and(|text| text.len() > MAX_AI_TASK_TEXT_OUTPUT_BYTES) + }) || task + .latest_text_output + .as_ref() + .is_some_and(|text| text.len() > MAX_AI_TASK_TEXT_OUTPUT_BYTES) + { + return Err("AI 任务文本输出超过内存上限"); + } + if task.stages.iter().any(|stage| { + stage + .structured_payload_json + .as_ref() + .is_some_and(|payload| payload.len() > MAX_AI_TASK_STRUCTURED_OUTPUT_BYTES) + }) || task + .latest_structured_payload_json + .as_ref() + .is_some_and(|payload| payload.len() > MAX_AI_TASK_STRUCTURED_OUTPUT_BYTES) + { + return Err("AI 任务结构化输出超过内存上限"); + } + if task.stages.iter().any(|stage| { + stage + .warning_messages + .iter() + .fold(0_usize, |total, warning| { + total.saturating_add(warning.len()) + }) + > MAX_AI_TASK_WARNING_BYTES + }) { + return Err("AI 任务 warning 输出超过内存上限"); + } + if task.result_references.len() > MAX_AI_TASK_RESULT_REFERENCES { + return Err("AI 任务结果引用数量超过内存上限"); + } + if task + .result_references + .iter() + .any(|reference| reference.reference_id.len() > MAX_AI_TASK_REFERENCE_ID_BYTES) + { + return Err("AI 任务结果引用 ID 超过内存上限"); + } + if task.result_references.iter().any(|reference| { + reference + .label + .as_ref() + .is_some_and(|label| label.len() > MAX_AI_TASK_REFERENCE_LABEL_BYTES) + }) { + return Err("AI 任务结果引用标签超过内存上限"); + } + Ok(()) +} diff --git a/server-rs/crates/module-ai/src/lib.rs b/server-rs/crates/module-ai/src/lib.rs index e69b5609f..a3311e4d9 100644 --- a/server-rs/crates/module-ai/src/lib.rs +++ b/server-rs/crates/module-ai/src/lib.rs @@ -14,9 +14,17 @@ pub use domain::{ AI_RESULT_REF_ID_PREFIX, AI_TASK_ID_PREFIX, AI_TASK_STAGE_ID_PREFIX, AI_TEXT_CHUNK_ID_PREFIX, AiResultReferenceKind, AiResultReferenceSnapshot, AiTaskKind, AiTaskSnapshot, AiTaskStageBlueprint, AiTaskStageKind, AiTaskStageSnapshot, AiTaskStageStatus, AiTaskStatus, - AiTextChunkSnapshot, INITIAL_AI_TASK_VERSION, generate_ai_result_ref_id, generate_ai_task_id, - generate_ai_task_stage_id, generate_ai_text_chunk_id, normalize_optional_text, - normalize_string_list, + AiTextChunkSnapshot, INITIAL_AI_TASK_VERSION, MAX_AI_TASK_FAILURE_MESSAGE_BYTES, + MAX_AI_TASK_ID_BYTES, MAX_AI_TASK_OWNER_USER_ID_BYTES, MAX_AI_TASK_REFERENCE_ID_BYTES, + MAX_AI_TASK_REFERENCE_LABEL_BYTES, MAX_AI_TASK_REQUEST_LABEL_BYTES, + MAX_AI_TASK_REQUEST_PAYLOAD_BYTES, MAX_AI_TASK_RESULT_REFERENCES, + MAX_AI_TASK_RETAINED_OUTPUT_BYTES, MAX_AI_TASK_RETAINED_TASKS, + MAX_AI_TASK_SOURCE_ENTITY_ID_BYTES, MAX_AI_TASK_SOURCE_MODULE_BYTES, + MAX_AI_TASK_STAGE_DETAIL_BYTES, MAX_AI_TASK_STAGE_LABEL_BYTES, + MAX_AI_TASK_STRUCTURED_OUTPUT_BYTES, MAX_AI_TASK_TEXT_CHUNKS_PER_STAGE, + MAX_AI_TASK_TEXT_OUTPUT_BYTES, MAX_AI_TASK_WARNING_BYTES, generate_ai_result_ref_id, + generate_ai_task_id, generate_ai_task_stage_id, generate_ai_text_chunk_id, + normalize_optional_text, normalize_string_list, validate_ai_task_snapshot_memory_limits, }; pub use errors::{AiTaskFieldError, AiTaskServiceError}; pub use events::AiTaskDomainEvent; diff --git a/server-rs/crates/module-ai/src/tests.rs b/server-rs/crates/module-ai/src/tests.rs index 766320035..a1ba8f6cb 100644 --- a/server-rs/crates/module-ai/src/tests.rs +++ b/server-rs/crates/module-ai/src/tests.rs @@ -43,6 +43,32 @@ fn create_task_rejects_duplicate_stage_blueprints() { assert_eq!(error, AiTaskFieldError::DuplicateStageBlueprint); } +#[test] +fn create_task_rejects_oversized_request_payload() { + let service = build_service(); + let mut input = build_create_input(AiTaskKind::StoryGeneration); + input.request_payload_json = Some("x".repeat(MAX_AI_TASK_REQUEST_PAYLOAD_BYTES + 1)); + + let error = service + .create_task(input) + .expect_err("request payload over the memory cap should fail"); + assert!( + matches!(error, AiTaskServiceError::Store(message) if message.contains("请求 payload")) + ); +} + +#[test] +fn create_task_rejects_oversized_request_metadata() { + let service = build_service(); + let mut input = build_create_input(AiTaskKind::StoryGeneration); + input.request_label = "x".repeat(MAX_AI_TASK_REQUEST_LABEL_BYTES + 1); + + let error = service + .create_task(input) + .expect_err("request metadata over the memory cap should fail"); + assert!(matches!(error, AiTaskServiceError::Store(message) if message.contains("请求标签"))); +} + #[test] fn generate_ai_task_stage_id_contains_task_and_stage_slug() { let stage_id = generate_ai_task_stage_id("aitask_demo", AiTaskStageKind::NormalizeResult); @@ -112,6 +138,47 @@ fn append_text_chunk_aggregates_stream_output_by_stage() { assert_eq!(second_chunk.sequence, 2); } +#[test] +fn append_text_chunk_rejects_output_over_stage_memory_limit_without_mutating_task() { + let service = build_service(); + let task = service + .create_task(build_create_input(AiTaskKind::CharacterChat)) + .expect("task should create"); + let max_output = "a".repeat(512 * 1024); + + let (updated, _) = service + .append_text_chunk( + &task.task_id, + AiTaskStageKind::RequestModel, + 1, + max_output.clone(), + task.created_at_micros + 1, + ) + .expect("the stage limit itself should be accepted"); + assert_eq!( + updated.latest_text_output.as_deref().map(str::len), + Some(max_output.len()) + ); + + let error = service + .append_text_chunk( + &task.task_id, + AiTaskStageKind::RequestModel, + 2, + "b".to_string(), + task.created_at_micros + 2, + ) + .expect_err("output beyond the stage limit should fail"); + assert!(matches!(error, AiTaskServiceError::Store(_))); + let after_rejection = service + .get_task(&task.task_id) + .expect("task should remain readable"); + assert_eq!( + after_rejection.latest_text_output.as_deref().map(str::len), + Some(max_output.len()) + ); +} + #[test] fn complete_stage_updates_latest_outputs() { let service = build_service(); @@ -147,6 +214,150 @@ fn complete_stage_updates_latest_outputs() { assert_eq!(stage.warning_messages, vec!["使用了 fallback 选项池"]); } +#[test] +fn complete_stage_rejects_oversized_text_output_without_mutating_task() { + let service = build_service(); + let task = service + .create_task(build_create_input(AiTaskKind::StoryGeneration)) + .expect("task should create"); + let oversized = "x".repeat(512 * 1024 + 1); + + let error = service + .complete_stage(AiStageCompletionInput { + task_id: task.task_id.clone(), + stage_kind: AiTaskStageKind::NormalizeResult, + text_output: Some(oversized), + structured_payload_json: None, + warning_messages: Vec::new(), + completed_at_micros: task.created_at_micros + 1, + }) + .expect_err("text output over the per-stage cap should fail"); + assert!(matches!(error, AiTaskServiceError::Store(message) if message.contains("文本输出"))); + + let unchanged = service + .get_task(&task.task_id) + .expect("task should remain readable"); + let stage = unchanged + .stages + .iter() + .find(|stage| stage.stage_kind == AiTaskStageKind::NormalizeResult) + .expect("normalize stage should exist"); + assert_eq!(stage.status, AiTaskStageStatus::Pending); + assert!(stage.text_output.is_none()); + assert!(unchanged.latest_text_output.is_none()); +} + +#[test] +fn complete_stage_rejects_oversized_structured_output_without_mutating_task() { + let service = build_service(); + let task = service + .create_task(build_create_input(AiTaskKind::StoryGeneration)) + .expect("task should create"); + let oversized = "x".repeat(512 * 1024 + 1); + + let error = service + .complete_stage(AiStageCompletionInput { + task_id: task.task_id.clone(), + stage_kind: AiTaskStageKind::NormalizeResult, + text_output: None, + structured_payload_json: Some(oversized), + warning_messages: Vec::new(), + completed_at_micros: task.created_at_micros + 1, + }) + .expect_err("structured output over the per-stage cap should fail"); + assert!(matches!(error, AiTaskServiceError::Store(message) if message.contains("结构化输出"))); + + let unchanged = service + .get_task(&task.task_id) + .expect("task should remain readable"); + let stage = unchanged + .stages + .iter() + .find(|stage| stage.stage_kind == AiTaskStageKind::NormalizeResult) + .expect("normalize stage should exist"); + assert_eq!(stage.status, AiTaskStageStatus::Pending); + assert!(stage.structured_payload_json.is_none()); + assert!(unchanged.latest_structured_payload_json.is_none()); +} + +#[test] +fn complete_stage_rejects_oversized_warning_output_without_mutating_task() { + let service = build_service(); + let task = service + .create_task(build_create_input(AiTaskKind::StoryGeneration)) + .expect("task should create"); + let oversized_warning = "w".repeat(64 * 1024 + 1); + + let error = service + .complete_stage(AiStageCompletionInput { + task_id: task.task_id.clone(), + stage_kind: AiTaskStageKind::NormalizeResult, + text_output: None, + structured_payload_json: None, + warning_messages: vec![oversized_warning], + completed_at_micros: task.created_at_micros + 1, + }) + .expect_err("warning output over the per-stage cap should fail"); + assert!(matches!(error, AiTaskServiceError::Store(message) if message.contains("warning"))); + + let unchanged = service + .get_task(&task.task_id) + .expect("task should remain readable"); + let stage = unchanged + .stages + .iter() + .find(|stage| stage.stage_kind == AiTaskStageKind::NormalizeResult) + .expect("normalize stage should exist"); + assert_eq!(stage.status, AiTaskStageStatus::Pending); + assert!(stage.warning_messages.is_empty()); +} + +#[test] +fn complete_stage_enforces_global_retained_output_cap() { + let service = build_service(); + let structured_payload = "x".repeat(512 * 1024); + for index in 0..63 { + let task = service + .create_task(AiTaskCreateInput { + task_id: format!("task-structured-cap-{index}"), + ..build_create_input(AiTaskKind::StoryGeneration) + }) + .expect("task should create"); + service + .complete_stage(AiStageCompletionInput { + task_id: task.task_id, + stage_kind: AiTaskStageKind::NormalizeResult, + text_output: None, + structured_payload_json: Some(structured_payload.clone()), + warning_messages: Vec::new(), + completed_at_micros: task.created_at_micros + 1, + }) + .expect("63 MiB retained output should remain within the global cap"); + } + + let task = service + .create_task(AiTaskCreateInput { + task_id: "task-structured-cap-overflow".to_string(), + ..build_create_input(AiTaskKind::StoryGeneration) + }) + .expect("the overflow candidate task itself should create"); + let error = service + .complete_stage(AiStageCompletionInput { + task_id: task.task_id.clone(), + stage_kind: AiTaskStageKind::NormalizeResult, + text_output: None, + structured_payload_json: Some(structured_payload), + warning_messages: Vec::new(), + completed_at_micros: task.created_at_micros + 1, + }) + .expect_err("global retained output cap should reject the overflow"); + assert!(matches!(error, AiTaskServiceError::Store(message) if message.contains("工作集"))); + let unchanged = service + .get_task(&task.task_id) + .expect("overflow task should remain readable"); + assert!(unchanged.latest_structured_payload_json.is_none()); +} + #[test] fn attach_result_reference_appends_binding() { let service = build_service(); @@ -172,6 +383,47 @@ fn attach_result_reference_appends_binding() { assert_eq!(updated.result_references[0].reference_id, "profile_001"); } +#[test] +fn attach_result_reference_rejects_unbounded_reference_growth() { + let service = build_service(); + let task = service + .create_task(build_create_input(AiTaskKind::CustomWorldGeneration)) + .expect("task should create"); + + for index in 0..MAX_AI_TASK_RESULT_REFERENCES { + service + .attach_result_reference( + &task.task_id, + AiResultReferenceKind::CustomWorldProfile, + format!("profile_{index}"), + None, + task.created_at_micros + index as i64 + 1, + ) + .expect("references within the cap should attach"); + } + + let error = service + .attach_result_reference( + &task.task_id, + AiResultReferenceKind::CustomWorldProfile, + "profile_overflow".to_string(), + None, + task.created_at_micros + MAX_AI_TASK_RESULT_REFERENCES as i64 + 1, + ) + .expect_err("references over the cap should fail"); + assert!( + matches!(error, AiTaskServiceError::Store(message) if message.contains("结果引用数量")) + ); + + let unchanged = service + .get_task(&task.task_id) + .expect("task should remain readable"); + assert_eq!( + unchanged.result_references.len(), + MAX_AI_TASK_RESULT_REFERENCES + ); +} + #[test] fn fail_and_cancel_task_move_into_terminal_states() { let service = build_service(); diff --git a/server-rs/crates/module-auth/src/domain.rs b/server-rs/crates/module-auth/src/domain.rs index e602d5dd5..4a29af646 100644 --- a/server-rs/crates/module-auth/src/domain.rs +++ b/server-rs/crates/module-auth/src/domain.rs @@ -81,7 +81,7 @@ pub struct PhoneNumberSnapshot { } /// 手机验证码使用场景。 -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum PhoneAuthScene { Login, BindPhone, @@ -101,7 +101,7 @@ impl PhoneAuthScene { } /// 微信授权入口场景。 -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum WechatAuthScene { Desktop, WechatInApp, @@ -187,6 +187,13 @@ pub struct AuthStoreProjectionView { pub users: Vec, pub identities: Vec, pub refresh_sessions: Vec, + #[serde(default)] + pub phone_codes: Vec, + #[serde(default)] + pub wechat_states: Vec, + /// 当前进程工作集所基于的正式投影版本,用于事务内 CAS。 + #[serde(default)] + pub base_updated_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -230,6 +237,31 @@ pub struct AuthStoreProjectionRefreshSession { pub last_seen_at: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthStoreProjectionPhoneCode { + pub phone_number: String, + pub scene: String, + pub verify_code_hash: String, + pub expires_at: String, + pub last_sent_at: String, + pub failed_attempts: u32, + pub provider_out_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthStoreProjectionWechatState { + pub wechat_state_id: String, + pub state_token: String, + pub redirect_path: String, + pub scene: String, + pub request_user_agent: Option, + pub bind_user_id: Option, + pub expires_at: String, + pub consumed_at: Option, + pub created_at: String, + pub updated_at: String, +} + pub fn validate_password(password: &str) -> Result<(), PasswordEntryError> { let length = password.chars().count(); if !(PASSWORD_MIN_LENGTH..=PASSWORD_MAX_LENGTH).contains(&length) { diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 6336495d0..c69c9cc7d 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -12,7 +12,10 @@ pub use events::*; use std::{ collections::{HashMap, HashSet}, - sync::{Arc, Mutex}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, }; use platform_auth::{ @@ -28,9 +31,17 @@ use shared_kernel::{ use time::{Duration, OffsetDateTime}; use tracing::{info, warn}; +const DEFAULT_PHONE_VERIFY_CODE_SALT: &str = "genarrative-phone-verify-code-v1"; +const PHONE_CODE_RESERVATION_MARKER: &str = "__genarrative_phone_code_reservation__"; +const MAX_ACTIVE_WECHAT_AUTH_STATES: usize = 1024; +const REFRESH_SESSION_STALE_RETENTION: Duration = Duration::days(1); +const MAX_REFRESH_SESSIONS: usize = 8_192; +const MAX_PHONE_CODES: usize = 4_096; + #[derive(Clone, Debug)] pub struct InMemoryAuthStore { inner: Arc>, + revision: Arc, } #[derive(Debug)] @@ -131,6 +142,24 @@ fn parse_auth_binding_status(value: &str) -> AuthBindingStatus { } } +fn parse_phone_auth_scene(value: &str) -> Option { + match value.trim() { + "login" => Some(PhoneAuthScene::Login), + "bind_phone" => Some(PhoneAuthScene::BindPhone), + "change_phone" => Some(PhoneAuthScene::ChangePhone), + "reset_password" => Some(PhoneAuthScene::ResetPassword), + _ => None, + } +} + +fn parse_wechat_auth_scene(value: &str) -> Option { + match value.trim() { + "desktop" => Some(WechatAuthScene::Desktop), + "wechat_in_app" => Some(WechatAuthScene::WechatInApp), + _ => None, + } +} + fn next_sequence_from_public_user_code(public_user_code: &str) -> u64 { public_user_code .trim() @@ -364,6 +393,7 @@ impl RefreshSessionService { input: CreateRefreshSessionInput, now: OffsetDateTime, ) -> Result { + self.store.prune_stale_sessions(now)?; self.store .find_by_user_id(&input.user_id) .map_err(map_password_store_error)? @@ -400,6 +430,7 @@ impl RefreshSessionService { input: RotateRefreshSessionInput, now: OffsetDateTime, ) -> Result { + self.store.prune_stale_sessions(now)?; let Some(refresh_token_hash) = normalize_required_string(&input.refresh_token_hash) else { return Err(RefreshSessionError::MissingToken); }; @@ -454,6 +485,7 @@ impl RefreshSessionService { user_id: &str, now: OffsetDateTime, ) -> Result { + self.store.prune_stale_sessions(now)?; self.store .find_by_user_id(user_id) .map_err(map_password_store_error)? @@ -468,6 +500,7 @@ impl RefreshSessionService { input: RevokeRefreshSessionByUserInput, now: OffsetDateTime, ) -> Result { + self.store.prune_stale_sessions(now)?; self.store .find_by_user_id(&input.user_id) .map_err(map_password_store_error)? @@ -492,6 +525,7 @@ impl RefreshSessionService { session_id: &str, now: OffsetDateTime, ) -> Result { + self.store.prune_stale_sessions(now)?; self.store .is_session_active_for_user(user_id, session_id.trim(), now) } @@ -499,10 +533,19 @@ impl RefreshSessionService { impl PhoneAuthService { pub fn new(store: InMemoryAuthStore, sms_provider: SmsAuthProvider) -> Self { + Self::new_with_verify_code_salt(store, sms_provider, DEFAULT_PHONE_VERIFY_CODE_SALT) + } + + /// 使用部署级稳定盐值构造服务,确保验证码投影恢复到另一节点后仍可校验。 + pub fn new_with_verify_code_salt( + store: InMemoryAuthStore, + sms_provider: SmsAuthProvider, + verify_code_salt: impl Into, + ) -> Self { Self { store, sms_provider, - verify_code_salt: new_uuid_simple_string(), + verify_code_salt: verify_code_salt.into(), } } @@ -511,6 +554,67 @@ impl PhoneAuthService { input: SendPhoneCodeInput, now: OffsetDateTime, ) -> Result { + self.send_code_inner(input, now, true).await + } + + /// 在 provider 调用前由 api-server 先同步该占位记录,以便 SpacetimeDB 的 + /// projection CAS 原子占用跨节点冷却窗口。 + pub fn reserve_code_send( + &self, + input: &SendPhoneCodeInput, + now: OffsetDateTime, + ) -> Result<(), PhoneAuthError> { + self.store.prune_expired_phone_codes(now)?; + let scene = input.scene.clone(); + validate_mainland_china_country_code(input.country_code.as_deref())?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; + self.store + .ensure_phone_code_not_cooling_down(&normalized_phone.e164, &scene, now)?; + let expires_at = now + .checked_add(Duration::minutes(SMS_CODE_TTL_MINUTES)) + .ok_or_else(|| PhoneAuthError::Store("短信验证码过期时间计算溢出".to_string()))?; + let expires_at = format_rfc3339(expires_at).map_err(|message| { + PhoneAuthError::Store(format!("短信验证码过期时间格式化失败:{message}")) + })?; + let last_sent_at = format_rfc3339(now).map_err(|message| { + PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}")) + })?; + let verify_code_hash = hash_phone_verify_code( + &self.verify_code_salt, + &normalized_phone.e164, + &scene, + PHONE_CODE_RESERVATION_MARKER, + ); + self.store.upsert_phone_code( + StoredPhoneCode { + phone_number: normalized_phone.e164, + scene, + verify_code_hash, + expires_at, + last_sent_at, + failed_attempts: 0, + provider_out_id: None, + }, + now, + ) + } + + /// 仅供完成权威占用后的 provider 调用使用;占用已由 projection CAS 校验。 + pub async fn send_code_after_authoritative_reservation( + &self, + input: SendPhoneCodeInput, + now: OffsetDateTime, + ) -> Result { + self.send_code_inner(input, now, false).await + } + + async fn send_code_inner( + &self, + input: SendPhoneCodeInput, + now: OffsetDateTime, + check_local_cooldown: bool, + ) -> Result { + self.store.prune_expired_phone_codes(now)?; let scene = input.scene.clone(); validate_mainland_china_country_code(input.country_code.as_deref())?; let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; @@ -523,8 +627,12 @@ impl PhoneAuthService { phone_national_masked = normalized_phone.masked_national_number.as_str(), "手机号验证码发送准备调用 provider" ); + if check_local_cooldown { + self.store + .ensure_phone_code_not_cooling_down(&normalized_phone.e164, &scene, now)?; + } self.store - .ensure_phone_code_not_cooling_down(&normalized_phone.e164, &scene, now)?; + .ensure_phone_code_capacity(&normalized_phone.e164, &scene)?; let expires_at = now .checked_add(Duration::minutes(SMS_CODE_TTL_MINUTES)) .ok_or_else(|| PhoneAuthError::Store("短信验证码过期时间计算溢出".to_string()))?; @@ -787,6 +895,7 @@ impl WechatAuthStateService { input: CreateWechatAuthStateInput, now: OffsetDateTime, ) -> Result { + self.store.prune_wechat_states(now)?; let created_at = format_rfc3339(now).map_err(|message| { WechatAuthError::Store(format!("微信 state 时间格式化失败:{message}")) })?; @@ -808,7 +917,7 @@ impl WechatAuthStateService { created_at: created_at.clone(), updated_at: created_at, }; - self.store.insert_wechat_state(state.clone())?; + self.store.insert_wechat_state(state.clone(), now)?; Ok(CreateWechatAuthStateResult { state }) } @@ -987,6 +1096,7 @@ impl Default for InMemoryAuthStore { fn default() -> Self { Self { inner: Arc::new(Mutex::new(InMemoryAuthStoreState::default())), + revision: Arc::new(AtomicU64::new(0)), } } } @@ -1021,6 +1131,8 @@ impl InMemoryAuthStoreState { let mut wechat_identity_by_provider_uid = HashMap::new(); let mut user_id_by_provider_union_id = HashMap::new(); let mut phone_number_by_user_id = HashMap::new(); + let mut phone_codes_by_key = HashMap::new(); + let mut wechat_states_by_token = HashMap::new(); for user in &view.users { if let Some(phone_number) = normalize_optional_string(user.phone_number_e164.clone()) { @@ -1063,10 +1175,25 @@ impl InMemoryAuthStoreState { } } + let now = OffsetDateTime::now_utc(); + let mut retained_refresh_session_count = 0_usize; for session in view.refresh_sessions { if !existing_user_ids.contains(&session.user_id) { continue; } + if should_prune_refresh_session_fields( + &session.expires_at, + session.revoked_at.as_deref(), + now, + ) { + continue; + } + retained_refresh_session_count += 1; + if retained_refresh_session_count > MAX_REFRESH_SESSIONS { + return Err(format!( + "认证投影中的 refresh session 数量超过内存上限(最多 {MAX_REFRESH_SESSIONS} 条)" + )); + } let client_info = serde_json::from_str::(&session.client_info_json) .map_err(|error| format!("解析 refresh session 客户端信息失败:{error}"))?; @@ -1093,6 +1220,46 @@ impl InMemoryAuthStoreState { ); } + for phone_code in view.phone_codes { + let scene = parse_phone_auth_scene(&phone_code.scene) + .ok_or_else(|| format!("未知短信验证码场景:{}", phone_code.scene))?; + let key = build_phone_code_key(&phone_code.phone_number, &scene); + phone_codes_by_key.insert( + key, + StoredPhoneCode { + phone_number: phone_code.phone_number, + scene, + verify_code_hash: phone_code.verify_code_hash, + expires_at: phone_code.expires_at, + last_sent_at: phone_code.last_sent_at, + failed_attempts: phone_code.failed_attempts, + provider_out_id: phone_code.provider_out_id, + }, + ); + } + + for wechat_state in view.wechat_states { + let scene = parse_wechat_auth_scene(&wechat_state.scene) + .ok_or_else(|| format!("未知微信授权 state 场景:{}", wechat_state.scene))?; + wechat_states_by_token.insert( + wechat_state.state_token.clone(), + StoredWechatAuthState { + state: WechatAuthStateRecord { + wechat_state_id: wechat_state.wechat_state_id, + state_token: wechat_state.state_token, + redirect_path: wechat_state.redirect_path, + scene, + request_user_agent: wechat_state.request_user_agent, + bind_user_id: wechat_state.bind_user_id, + expires_at: wechat_state.expires_at, + consumed_at: wechat_state.consumed_at, + created_at: wechat_state.created_at, + updated_at: wechat_state.updated_at, + }, + }, + ); + } + for user in view.users { let wechat_identity = wechat_identity_by_provider_uid .values() @@ -1140,8 +1307,8 @@ impl InMemoryAuthStoreState { phone_to_user_id, sessions_by_id, session_id_by_refresh_token_hash, - phone_codes_by_key: HashMap::new(), - wechat_states_by_token: HashMap::new(), + phone_codes_by_key, + wechat_states_by_token, wechat_identity_by_provider_uid, user_id_by_provider_union_id, }) @@ -1153,20 +1320,47 @@ impl InMemoryAuthStoreState { self.phone_to_user_id = next_state.phone_to_user_id; self.sessions_by_id = next_state.sessions_by_id; self.session_id_by_refresh_token_hash = next_state.session_id_by_refresh_token_hash; + self.phone_codes_by_key = next_state.phone_codes_by_key; + self.wechat_states_by_token = next_state.wechat_states_by_token; self.wechat_identity_by_provider_uid = next_state.wechat_identity_by_provider_uid; self.user_id_by_provider_union_id = next_state.user_id_by_provider_union_id; } } +fn prune_expired_short_lived_state( + state: &mut InMemoryAuthStoreState, + now: OffsetDateTime, +) -> bool { + let phone_code_count = state.phone_codes_by_key.len(); + state.phone_codes_by_key.retain(|_, code| { + parse_rfc3339(&code.expires_at) + .map(|expires_at| expires_at > now) + .unwrap_or(true) + }); + let wechat_state_count = state.wechat_states_by_token.len(); + state.wechat_states_by_token.retain(|_, stored| { + parse_rfc3339(&stored.state.expires_at) + .map(|expires_at| expires_at > now) + .unwrap_or(true) + }); + phone_code_count != state.phone_codes_by_key.len() + || wechat_state_count != state.wechat_states_by_token.len() +} + impl InMemoryAuthStore { pub fn from_projection_view(view: AuthStoreProjectionView) -> Result { Ok(Self { inner: Arc::new(Mutex::new(InMemoryAuthStoreState::from_projection_view( view, )?)), + revision: Arc::new(AtomicU64::new(0)), }) } + pub fn revision(&self) -> u64 { + self.revision.load(Ordering::Acquire) + } + pub fn refresh_from_projection_view( &self, view: AuthStoreProjectionView, @@ -1177,18 +1371,44 @@ impl InMemoryAuthStore { .lock() .map_err(|_| "认证仓储锁已中毒".to_string())?; state.apply_persistent_state(next_state); + self.revision.fetch_add(1, Ordering::Release); Ok(()) } + pub fn refresh_from_projection_view_if_revision( + &self, + view: AuthStoreProjectionView, + expected_revision: u64, + ) -> Result { + let next_state = InMemoryAuthStoreState::from_projection_view(view)?; + let mut state = self + .inner + .lock() + .map_err(|_| "认证仓储锁已中毒".to_string())?; + if self.revision.load(Ordering::Acquire) != expected_revision { + return Ok(false); + } + state.apply_persistent_state(next_state); + self.revision.fetch_add(1, Ordering::Release); + + Ok(true) + } + pub fn export_projection_view( &self, updated_at_micros: i64, ) -> Result { - let state = self + self.prune_stale_sessions(OffsetDateTime::now_utc()) + .map_err(|error| error.to_string())?; + let mut state = self .inner .lock() .map_err(|_| "认证仓储锁已中毒".to_string())?; + let pruned = prune_expired_short_lived_state(&mut state, OffsetDateTime::now_utc()); + if pruned { + self.revision.fetch_add(1, Ordering::Release); + } let users = state .users_by_username .values() @@ -1253,17 +1473,97 @@ impl InMemoryAuthStore { }) }) .collect::, String>>()?; + let phone_codes = state + .phone_codes_by_key + .values() + .map(|stored| AuthStoreProjectionPhoneCode { + phone_number: stored.phone_number.clone(), + scene: stored.scene.as_str().to_string(), + verify_code_hash: stored.verify_code_hash.clone(), + expires_at: stored.expires_at.clone(), + last_sent_at: stored.last_sent_at.clone(), + failed_attempts: stored.failed_attempts, + provider_out_id: stored.provider_out_id.clone(), + }) + .collect(); + let wechat_states = state + .wechat_states_by_token + .values() + .map(|stored| AuthStoreProjectionWechatState { + wechat_state_id: stored.state.wechat_state_id.clone(), + state_token: stored.state.state_token.clone(), + redirect_path: stored.state.redirect_path.clone(), + scene: stored.state.scene.as_str().to_string(), + request_user_agent: stored.state.request_user_agent.clone(), + bind_user_id: stored.state.bind_user_id.clone(), + expires_at: stored.state.expires_at.clone(), + consumed_at: stored.state.consumed_at.clone(), + created_at: stored.state.created_at.clone(), + updated_at: stored.state.updated_at.clone(), + }) + .collect(); Ok(AuthStoreProjectionView { + base_updated_at_micros: 0, updated_at_micros, users, identities, refresh_sessions, + phone_codes, + wechat_states, }) } + pub fn export_projection_view_with_revision( + &self, + updated_at_micros: i64, + ) -> Result<(AuthStoreProjectionView, u64), String> { + for _ in 0..3 { + let before = self.revision.load(Ordering::Acquire); + let view = self.export_projection_view(updated_at_micros)?; + let after = self.revision.load(Ordering::Acquire); + if before == after { + return Ok((view, after)); + } + } + Err("认证工作集在导出期间持续发生变化".to_string()) + } + + fn prune_stale_sessions(&self, now: OffsetDateTime) -> Result<(), RefreshSessionError> { + let mut state = self + .inner + .lock() + .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; + let stale_session_ids = state + .sessions_by_id + .iter() + .filter(|(_, stored)| should_prune_refresh_session(&stored.session, now)) + .map(|(session_id, _)| session_id.clone()) + .collect::>(); + if stale_session_ids.is_empty() { + return Ok(()); + } + + for session_id in stale_session_ids { + let Some(stored) = state.sessions_by_id.remove(&session_id) else { + continue; + }; + if state + .session_id_by_refresh_token_hash + .get(&stored.session.refresh_token_hash) + .is_some_and(|mapped_id| mapped_id == &session_id) + { + state + .session_id_by_refresh_token_hash + .remove(&stored.session.refresh_token_hash); + } + } + self.persist_refresh_state(&state) + } + fn persist_state(&self, state: &InMemoryAuthStoreState) -> Result<(), String> { let _ = state; + self.revision.fetch_add(1, Ordering::Release); Ok(()) } @@ -1894,6 +2194,11 @@ impl InMemoryAuthStore { "refresh token hash 已存在,无法重复创建会话".to_string(), )); } + if state.sessions_by_id.len() >= MAX_REFRESH_SESSIONS { + return Err(RefreshSessionError::Store( + "refresh session 内存容量已达到上限".to_string(), + )); + } state.session_id_by_refresh_token_hash.insert( session.refresh_token_hash.clone(), @@ -1918,7 +2223,39 @@ impl InMemoryAuthStore { .map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?; // 手机号和业务场景共同决定同一份验证码快照,重复发送时直接覆盖旧值。 let key = build_phone_code_key(&code.phone_number, &code.scene); + if !state.phone_codes_by_key.contains_key(&key) + && state.phone_codes_by_key.len() >= MAX_PHONE_CODES + { + return Err(PhoneAuthError::Store( + "短信验证码内存容量已达到上限,请稍后重试".to_string(), + )); + } state.phone_codes_by_key.insert(key, code); + self.persist_phone_state(&state)?; + Ok(()) + } + + fn prune_expired_phone_codes(&self, now: OffsetDateTime) -> Result<(), PhoneAuthError> { + let mut state = self + .inner + .lock() + .map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?; + let expired_keys = state + .phone_codes_by_key + .iter() + .filter_map(|(key, stored)| { + OffsetDateTime::parse( + &stored.expires_at, + &time::format_description::well_known::Rfc3339, + ) + .ok() + .filter(|expires_at| *expires_at <= now) + .map(|_| key.clone()) + }) + .collect::>(); + for key in expired_keys { + state.phone_codes_by_key.remove(&key); + } Ok(()) } @@ -1961,6 +2298,26 @@ impl InMemoryAuthStore { }) } + fn ensure_phone_code_capacity( + &self, + phone_number: &str, + scene: &PhoneAuthScene, + ) -> Result<(), PhoneAuthError> { + let state = self + .inner + .lock() + .map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?; + let key = build_phone_code_key(phone_number, scene); + if state.phone_codes_by_key.contains_key(&key) + || state.phone_codes_by_key.len() < MAX_PHONE_CODES + { + return Ok(()); + } + Err(PhoneAuthError::Store( + "短信验证码内存容量已达到上限,请稍后重试".to_string(), + )) + } + fn get_active_phone_code( &self, phone_number: &str, @@ -2000,6 +2357,7 @@ impl InMemoryAuthStore { .map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?; let key = build_phone_code_key(phone_number, scene); state.phone_codes_by_key.remove(&key); + self.persist_phone_state(&state)?; Ok(()) } @@ -2019,34 +2377,44 @@ impl InMemoryAuthStore { let next_failed_attempts = stored.failed_attempts.saturating_add(1); if next_failed_attempts >= SMS_CODE_MAX_FAILED_ATTEMPTS { state.phone_codes_by_key.remove(&key); + self.persist_phone_state(&state)?; return Err(PhoneAuthError::VerifyAttemptsExceeded); } if let Some(current) = state.phone_codes_by_key.get_mut(&key) { current.failed_attempts = next_failed_attempts; } + self.persist_phone_state(&state)?; Err(PhoneAuthError::InvalidVerifyCode) } fn insert_wechat_state( &self, state_record: WechatAuthStateRecord, + now: OffsetDateTime, ) -> Result<(), WechatAuthError> { let mut state = self .inner .lock() .map_err(|_| WechatAuthError::Store("微信 state 仓储锁已中毒".to_string()))?; + prune_expired_short_lived_state(&mut state, now); if state .wechat_states_by_token .contains_key(&state_record.state_token) { return Err(WechatAuthError::Store("微信 state 已存在".to_string())); } + if state.wechat_states_by_token.len() >= MAX_ACTIVE_WECHAT_AUTH_STATES { + return Err(WechatAuthError::Store( + "微信登录请求过多,请稍后重试".to_string(), + )); + } state.wechat_states_by_token.insert( state_record.state_token.clone(), StoredWechatAuthState { state: state_record, }, ); + self.persist_wechat_state(&state)?; Ok(()) } @@ -2084,7 +2452,10 @@ impl InMemoryAuthStore { .ok_or(WechatAuthError::StateNotFound)?; current.state.consumed_at = Some(now_iso.clone()); current.state.updated_at = now_iso; - Ok(current.clone()) + let consumed = current.clone(); + state.wechat_states_by_token.remove(state_token.trim()); + self.persist_wechat_state(&state)?; + Ok(consumed) } fn bind_wechat_phone_to_user( @@ -2405,6 +2776,33 @@ impl InMemoryAuthStore { Ok(()) } + fn prune_wechat_states(&self, now: OffsetDateTime) -> Result<(), WechatAuthError> { + let mut state = self + .inner + .lock() + .map_err(|_| WechatAuthError::Store("微信 state 仓储锁已中毒".to_string()))?; + let stale_tokens = state + .wechat_states_by_token + .iter() + .filter_map(|(token, stored)| { + if stored.state.consumed_at.is_some() { + return Some(token.clone()); + } + OffsetDateTime::parse( + &stored.state.expires_at, + &time::format_description::well_known::Rfc3339, + ) + .ok() + .filter(|expires_at| *expires_at <= now) + .map(|_| token.clone()) + }) + .collect::>(); + for token in stale_tokens { + state.wechat_states_by_token.remove(&token); + } + Ok(()) + } + fn revoke_session_by_user_and_session_id( &self, user_id: &str, @@ -2568,6 +2966,25 @@ impl InMemoryAuthStore { } } +fn should_prune_refresh_session(session: &RefreshSessionRecord, now: OffsetDateTime) -> bool { + should_prune_refresh_session_fields(&session.expires_at, session.revoked_at.as_deref(), now) +} + +fn should_prune_refresh_session_fields( + expires_at: &str, + revoked_at: Option<&str>, + now: OffsetDateTime, +) -> bool { + let stale_before = now.saturating_sub(REFRESH_SESSION_STALE_RETENTION); + if let Some(revoked_at) = revoked_at { + return OffsetDateTime::parse(revoked_at, &time::format_description::well_known::Rfc3339) + .is_ok_and(|timestamp| timestamp <= stale_before); + } + + OffsetDateTime::parse(expires_at, &time::format_description::well_known::Rfc3339) + .is_ok_and(|timestamp| timestamp <= stale_before) +} + fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthError { match error { SmsProviderError::InvalidVerifyCode => PhoneAuthError::InvalidVerifyCode, @@ -2783,10 +3200,13 @@ mod tests { fn empty_projection_store() -> InMemoryAuthStore { InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { + base_updated_at_micros: 0, updated_at_micros: 0, users: vec![], identities: vec![], refresh_sessions: vec![], + phone_codes: vec![], + wechat_states: vec![], }) .expect("projection should restore") } @@ -3208,6 +3628,7 @@ mod tests { async fn phone_login_reuses_user_restored_from_projection() { let phone_service = build_phone_service( InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { + base_updated_at_micros: 0, updated_at_micros: 1, users: vec![projection_user( "user_existing_phone", @@ -3216,6 +3637,8 @@ mod tests { )], identities: vec![], refresh_sessions: vec![], + phone_codes: vec![], + wechat_states: vec![], }) .expect("projection should restore"), ); @@ -3298,6 +3721,98 @@ mod tests { assert_eq!(rotated.user.id, user.id); } + #[tokio::test] + async fn projection_roundtrip_preserves_phone_code_and_wechat_state() { + let store = InMemoryAuthStore::default(); + let phone_service = build_phone_service(store.clone()); + let wechat_state_service = WechatAuthStateService::new(store.clone(), 5); + let now = OffsetDateTime::now_utc(); + + phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138040".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .await + .expect("phone code should send before projection export"); + let created_state = wechat_state_service + .create_state( + CreateWechatAuthStateInput { + redirect_path: "/studio".to_string(), + scene: WechatAuthScene::Desktop, + request_user_agent: Some("test-agent".to_string()), + bind_user_id: None, + }, + now, + ) + .expect("wechat state should be created before projection export"); + + let projection = store + .export_projection_view(1) + .expect("projection export should include short-lived auth state"); + assert_eq!(projection.phone_codes.len(), 1); + assert_eq!(projection.wechat_states.len(), 1); + + let restored_store = InMemoryAuthStore::from_projection_view(projection) + .expect("projection should restore short-lived auth state"); + let restored_phone_service = build_phone_service(restored_store.clone()); + let login = restored_phone_service + .login( + PhoneLoginInput { + country_code: None, + pure_phone_number: "13800138040".to_string(), + verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), + }, + now + Duration::seconds(1), + ) + .await + .expect("restored phone code should verify"); + assert!(login.created); + + let consumed_state = WechatAuthStateService::new(restored_store, 5) + .consume_state(&created_state.state.state_token, now + Duration::seconds(1)) + .expect("restored wechat state should be consumable"); + assert_eq!(consumed_state.state.redirect_path, "/studio"); + } + + #[test] + fn wechat_state_creation_is_bounded_before_projection_sync() { + let store = InMemoryAuthStore::default(); + let service = WechatAuthStateService::new(store, 5); + let now = OffsetDateTime::now_utc(); + + for index in 0..MAX_ACTIVE_WECHAT_AUTH_STATES { + service + .create_state( + CreateWechatAuthStateInput { + redirect_path: format!("/studio?attempt={index}"), + scene: WechatAuthScene::Desktop, + request_user_agent: None, + bind_user_id: None, + }, + now, + ) + .expect("active wechat state should fit within the projection budget"); + } + + let error = service + .create_state( + CreateWechatAuthStateInput { + redirect_path: "/studio".to_string(), + scene: WechatAuthScene::Desktop, + request_user_agent: None, + bind_user_id: None, + }, + now, + ) + .expect_err("wechat state creation must reject an unbounded projection"); + assert!(matches!(error, WechatAuthError::Store(message) if message.contains("请求过多"))); + } + #[tokio::test] async fn refresh_from_projection_view_merges_session_created_by_another_process() { let source_store = InMemoryAuthStore::default(); @@ -3344,19 +3859,45 @@ mod tests { ) .expect("refreshed session active check should succeed") ); - assert!(matches!( - local_phone_service - .send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138034".to_string(), - scene: PhoneAuthScene::Login, - }, - local_now + Duration::seconds(5), - ) - .await, - Err(PhoneAuthError::SendCoolingDown { .. }) - )); + // 刷新到数据库正式投影后,短期认证状态也以数据库快照为准;本地未同步的验证码 + // 不得继续留在工作集里,避免消费已被其他节点清理的验证码。 + local_phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138034".to_string(), + scene: PhoneAuthScene::Login, + }, + local_now + Duration::seconds(5), + ) + .await + .expect("phone code should be resendable after authoritative refresh"); + } + + #[test] + fn conditional_projection_refresh_rejects_stale_revision() { + let store = InMemoryAuthStore::default(); + let projection = AuthStoreProjectionView { + base_updated_at_micros: 0, + updated_at_micros: 1, + users: vec![], + identities: vec![], + refresh_sessions: vec![], + phone_codes: vec![], + wechat_states: vec![], + }; + + assert_eq!(store.revision(), 0); + store + .refresh_from_projection_view(projection.clone()) + .expect("initial projection refresh should succeed"); + assert_eq!(store.revision(), 1); + assert!( + !store + .refresh_from_projection_view_if_revision(projection, 0) + .expect("stale projection refresh should be checked without error") + ); + assert_eq!(store.revision(), 1); } #[tokio::test] @@ -3463,6 +4004,30 @@ mod tests { } } + #[tokio::test] + async fn authoritative_phone_code_reservation_blocks_duplicate_provider_send() { + let service = build_phone_service(build_store()); + let input = SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138001".to_string(), + scene: PhoneAuthScene::Login, + }; + let now = OffsetDateTime::now_utc(); + + service + .reserve_code_send(&input, now) + .expect("authoritative reservation should be representable locally"); + let duplicate = service + .reserve_code_send(&input, now + Duration::seconds(1)) + .expect_err("a second reservation must observe the local cooldown"); + assert!(matches!(duplicate, PhoneAuthError::SendCoolingDown { .. })); + + service + .send_code_after_authoritative_reservation(input, now) + .await + .expect("provider send should replace the reservation with the real code"); + } + #[tokio::test] async fn phone_send_code_keeps_different_scenes_isolated() { let service = build_phone_service(build_store()); @@ -4012,6 +4577,108 @@ mod tests { ); } + #[tokio::test] + async fn stale_refresh_sessions_are_pruned_from_both_indexes() { + let store = build_store(); + let refresh_service = build_refresh_service(store.clone()); + let user = create_phone_login_user(store.clone(), "13800138008").await; + let now = OffsetDateTime::now_utc(); + + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: hash_refresh_session_token("stale-revoked"), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + now - Duration::days(2), + ) + .expect("stale session should create"); + store + .revoke_session_by_refresh_token_hash( + &hash_refresh_session_token("stale-revoked"), + now - Duration::days(2), + ) + .expect("stale session should revoke"); + + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: hash_refresh_session_token("recent-revoked"), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + now, + ) + .expect("recent session should create"); + store + .revoke_session_by_refresh_token_hash( + &hash_refresh_session_token("recent-revoked"), + now, + ) + .expect("recent session should revoke"); + + let projection = store + .export_projection_view(now.unix_timestamp()) + .expect("projection export should prune stale sessions"); + assert_eq!(projection.refresh_sessions.len(), 1); + assert_eq!( + projection.refresh_sessions[0].refresh_token_hash, + hash_refresh_session_token("recent-revoked") + ); + + let stale_error = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: hash_refresh_session_token("stale-revoked"), + next_refresh_token_hash: hash_refresh_session_token("stale-next"), + }, + now, + ) + .expect_err("pruned session should no longer be indexed"); + assert_eq!(stale_error, RefreshSessionError::SessionNotFound); + } + + #[test] + fn projection_restore_rejects_too_many_retained_refresh_sessions() { + let client_info_json = + serde_json::to_string(&build_client_info()).expect("client info should serialize"); + let refresh_sessions = (0..=MAX_REFRESH_SESSIONS) + .map(|index| AuthStoreProjectionRefreshSession { + session_id: format!("session-{index}"), + user_id: "user_projection_cap".to_string(), + refresh_token_hash: format!("hash-{index}"), + issued_by_provider: "password".to_string(), + client_info_json: client_info_json.clone(), + expires_at: "2999-01-01T00:00:00Z".to_string(), + revoked_at: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + last_seen_at: "2026-01-01T00:00:00Z".to_string(), + }) + .collect(); + + let error = InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { + base_updated_at_micros: 0, + updated_at_micros: 1, + users: vec![projection_user( + "user_projection_cap", + "projection_cap", + None, + )], + identities: vec![], + refresh_sessions, + phone_codes: vec![], + wechat_states: vec![], + }) + .expect_err("projection restore must enforce the refresh session cap"); + + assert!(error.contains("refresh session")); + assert!(error.contains(&MAX_REFRESH_SESSIONS.to_string())); + } + #[tokio::test] async fn wechat_login_hits_existing_user_by_union_id_before_openid() { let store = build_store(); @@ -4248,6 +4915,7 @@ mod tests { #[tokio::test] async fn bind_wechat_phone_merges_when_existing_phone_restored_from_projection() { let store = InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { + base_updated_at_micros: 0, updated_at_micros: 1, users: vec![projection_user( "user_existing_phone_bind", @@ -4256,6 +4924,8 @@ mod tests { )], identities: vec![], refresh_sessions: vec![], + phone_codes: vec![], + wechat_states: vec![], }) .expect("projection should restore"); let phone_service = build_phone_service(store.clone()); diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index d282cb10e..0fc648db8 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -285,6 +285,59 @@ pub fn build_runtime_profile_wallet_adjustment_input_with_metadata( }) } +pub fn build_runtime_profile_wallet_refund_outbox_enqueue_input( + owner_user_id: String, + amount: u64, + refund_ledger_id: String, + created_at_micros: i64, + asset_kind: String, + asset_id: String, + settlement_reason: String, + external_generation_job_id: Option, + external_generation_claim_attempt: Option, +) -> Result { + let adjustment = build_runtime_profile_wallet_adjustment_input( + owner_user_id, + amount, + refund_ledger_id, + created_at_micros, + )?; + let asset_kind = + normalize_required_string(asset_kind).ok_or(RuntimeProfileFieldError::MissingLedgerId)?; + let asset_id = + normalize_required_string(asset_id).ok_or(RuntimeProfileFieldError::MissingLedgerId)?; + let settlement_reason = normalize_required_string(settlement_reason) + .ok_or(RuntimeProfileFieldError::MissingLedgerId)?; + let external_generation_job_id = + external_generation_job_id.and_then(|value| normalize_required_string(value)); + if external_generation_job_id.is_some() != external_generation_claim_attempt.is_some() { + return Err(RuntimeProfileFieldError::InvalidExternalGenerationAttempt); + } + Ok(RuntimeProfileWalletRefundOutboxEnqueueInput { + owner_user_id: adjustment.user_id, + amount: adjustment.amount, + refund_ledger_id: adjustment.ledger_id, + created_at_micros: adjustment.created_at_micros, + asset_kind, + asset_id, + settlement_reason, + external_generation_job_id, + external_generation_claim_attempt, + }) +} + +pub fn build_runtime_profile_wallet_refund_outbox_process_input( + worker_id: String, + limit: u32, +) -> Result { + let worker_id = + normalize_required_string(worker_id).ok_or(RuntimeProfileFieldError::MissingLedgerId)?; + if limit == 0 { + return Err(RuntimeProfileFieldError::InvalidWalletAmount); + } + Ok(RuntimeProfileWalletRefundOutboxProcessInput { worker_id, limit }) +} + pub fn build_runtime_profile_recharge_center_get_input( user_id: String, ) -> Result { diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index d4334fc1f..7a4a8c928 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -2077,6 +2077,38 @@ pub struct RuntimeProfileWalletAdjustmentProcedureResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileWalletRefundOutboxEnqueueInput { + pub owner_user_id: String, + pub amount: u64, + pub refund_ledger_id: String, + pub created_at_micros: i64, + pub asset_kind: String, + pub asset_id: String, + pub settlement_reason: String, + pub external_generation_job_id: Option, + pub external_generation_claim_attempt: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileWalletRefundOutboxProcessInput { + pub worker_id: String, + pub limit: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileWalletRefundOutboxProcedureResult { + pub ok: bool, + pub enqueued_count: u32, + pub processed_count: u32, + pub retry_count: u32, + pub failed_count: u32, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileWalletLedgerListInput { diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index 74e1924ed..e8f1f0457 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -82,6 +82,7 @@ pub enum RuntimeProfileFieldError { TaskNotClaimable, TaskAlreadyClaimed, MissingWorkerId, + InvalidExternalGenerationAttempt, MissingOrderId, MissingProductId, MissingProductTitle, @@ -172,6 +173,9 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::TaskNotClaimable => f.write_str("任务尚未达成"), Self::TaskAlreadyClaimed => f.write_str("任务奖励已领取"), Self::MissingWorkerId => f.write_str("worker_id 不能为空"), + Self::InvalidExternalGenerationAttempt => { + f.write_str("external_generation_job_id 与 claim_attempt 必须成对提供") + } Self::MissingOrderId => f.write_str("recharge.order_id 不能为空"), Self::MissingProductId => f.write_str("recharge.product_id 不能为空"), Self::MissingProductTitle => f.write_str("recharge.product_title 不能为空"), diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 2d925be39..66845286e 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -38,6 +38,7 @@ pub use self::ai::{ AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord, AiTextChunkRecord, }; +pub use self::auth::AuthSessionValidationRecordInput; pub use self::editor_agent::{ EditorAgentConversationCreateRecordInput, EditorAgentConversationDeleteRecordInput, EditorAgentConversationRecord, EditorAgentConversationTouchRecordInput, @@ -93,8 +94,8 @@ pub(crate) use self::assets::{ map_optional_asset_object_procedure_result, map_procedure_result, }; pub(crate) use self::auth::{ - map_auth_store_projection_procedure_result, map_auth_store_projection_sync_procedure_result, - map_auth_store_projection_view_input, + map_auth_session_validation_result, map_auth_store_projection_procedure_result, + map_auth_store_projection_sync_procedure_result, map_auth_store_projection_view_input, }; pub(crate) use self::editor_agent::{ map_editor_agent_conversation_list_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/auth.rs b/server-rs/crates/spacetime-client/src/active/mapper/auth.rs index e0ba264bf..3195f4514 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/auth.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/auth.rs @@ -1,5 +1,22 @@ use super::*; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthSessionValidationRecordInput { + pub user_id: String, + pub session_id: String, + pub token_version: u64, +} + +pub(crate) fn map_auth_session_validation_result( + result: crate::module_bindings::AuthSessionValidationProcedureResult, +) -> Result { + if result.error_message.is_some() { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result.active) +} + pub(crate) fn map_auth_store_projection_procedure_result( result: crate::module_bindings::AuthStoreProjectionProcedureResult, ) -> Result { @@ -35,6 +52,7 @@ pub(crate) fn map_auth_store_projection_view_input( view: module_auth::AuthStoreProjectionView, ) -> crate::module_bindings::AuthStoreProjectionView { crate::module_bindings::AuthStoreProjectionView { + base_updated_at_micros: view.base_updated_at_micros, updated_at_micros: view.updated_at_micros, users: view .users @@ -87,6 +105,39 @@ pub(crate) fn map_auth_store_projection_view_input( }, ) .collect(), + phone_codes: view + .phone_codes + .into_iter() + .map( + |code| crate::module_bindings::AuthStoreProjectionPhoneCode { + phone_number: code.phone_number, + scene: code.scene, + verify_code_hash: code.verify_code_hash, + expires_at: code.expires_at, + last_sent_at: code.last_sent_at, + failed_attempts: code.failed_attempts, + provider_out_id: code.provider_out_id, + }, + ) + .collect(), + wechat_states: view + .wechat_states + .into_iter() + .map( + |state| crate::module_bindings::AuthStoreProjectionWechatState { + wechat_state_id: state.wechat_state_id, + state_token: state.state_token, + redirect_path: state.redirect_path, + scene: state.scene, + request_user_agent: state.request_user_agent, + bind_user_id: state.bind_user_id, + expires_at: state.expires_at, + consumed_at: state.consumed_at, + created_at: state.created_at, + updated_at: state.updated_at, + }, + ) + .collect(), } } @@ -94,6 +145,7 @@ fn map_auth_store_projection_view( view: crate::module_bindings::AuthStoreProjectionView, ) -> module_auth::AuthStoreProjectionView { module_auth::AuthStoreProjectionView { + base_updated_at_micros: view.base_updated_at_micros, updated_at_micros: view.updated_at_micros, users: view .users @@ -142,5 +194,34 @@ fn map_auth_store_projection_view( last_seen_at: session.last_seen_at, }) .collect(), + phone_codes: view + .phone_codes + .into_iter() + .map(|code| module_auth::AuthStoreProjectionPhoneCode { + phone_number: code.phone_number, + scene: code.scene, + verify_code_hash: code.verify_code_hash, + expires_at: code.expires_at, + last_sent_at: code.last_sent_at, + failed_attempts: code.failed_attempts, + provider_out_id: code.provider_out_id, + }) + .collect(), + wechat_states: view + .wechat_states + .into_iter() + .map(|state| module_auth::AuthStoreProjectionWechatState { + wechat_state_id: state.wechat_state_id, + state_token: state.state_token, + redirect_path: state.redirect_path, + scene: state.scene, + request_user_agent: state.request_user_agent, + bind_user_id: state.bind_user_id, + expires_at: state.expires_at, + consumed_at: state.consumed_at, + created_at: state.created_at, + updated_at: state.updated_at, + }) + .collect(), } } diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index 1077b5b0c..4573466b9 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -117,6 +117,35 @@ impl From } } +impl From + for RuntimeProfileWalletRefundOutboxEnqueueInput +{ + fn from(input: module_runtime::RuntimeProfileWalletRefundOutboxEnqueueInput) -> Self { + Self { + owner_user_id: input.owner_user_id, + amount: input.amount, + refund_ledger_id: input.refund_ledger_id, + created_at_micros: input.created_at_micros, + asset_kind: input.asset_kind, + asset_id: input.asset_id, + settlement_reason: input.settlement_reason, + external_generation_job_id: input.external_generation_job_id, + external_generation_claim_attempt: input.external_generation_claim_attempt, + } + } +} + +impl From + for RuntimeProfileWalletRefundOutboxProcessInput +{ + fn from(input: module_runtime::RuntimeProfileWalletRefundOutboxProcessInput) -> Self { + Self { + worker_id: input.worker_id, + limit: input.limit, + } + } +} + impl From for RuntimeProfileRechargeOrderGetInput { @@ -677,6 +706,24 @@ pub(crate) fn map_runtime_profile_wallet_adjustment_procedure_result( )) } +pub(crate) fn map_runtime_profile_wallet_refund_outbox_procedure_result( + result: RuntimeProfileWalletRefundOutboxProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok( + module_runtime::RuntimeProfileWalletRefundOutboxProcedureResult { + ok: true, + enqueued_count: result.enqueued_count, + processed_count: result.processed_count, + retry_count: result.retry_count, + failed_count: result.failed_count, + error_message: result.error_message, + }, + ) +} + pub(crate) fn map_runtime_profile_recharge_center_procedure_result( result: RuntimeProfileRechargeCenterProcedureResult, ) -> Result { diff --git a/server-rs/crates/spacetime-client/src/active/runtime.rs b/server-rs/crates/spacetime-client/src/active/runtime.rs index f11c2cb89..119393fb1 100644 --- a/server-rs/crates/spacetime-client/src/active/runtime.rs +++ b/server-rs/crates/spacetime-client/src/active/runtime.rs @@ -312,6 +312,66 @@ impl SpacetimeClient { .await } + pub async fn enqueue_profile_wallet_refund_outbox( + &self, + input: module_runtime::RuntimeProfileWalletRefundOutboxEnqueueInput, + ) -> Result + { + let procedure_input: RuntimeProfileWalletRefundOutboxEnqueueInput = input.into(); + self.call_after_connect( + "enqueue_profile_wallet_refund_outbox_and_return", + move |connection, sender| { + connection + .procedures() + .enqueue_profile_wallet_refund_outbox_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_wallet_refund_outbox_procedure_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn process_profile_wallet_refund_outbox( + &self, + worker_id: String, + limit: u32, + ) -> Result + { + let procedure_input = + module_runtime::build_runtime_profile_wallet_refund_outbox_process_input( + worker_id, limit, + ) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + self.call_after_connect( + "process_profile_wallet_refund_outbox_and_return", + move |connection, sender| { + connection + .procedures() + .process_profile_wallet_refund_outbox_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_wallet_refund_outbox_procedure_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn get_profile_recharge_center( &self, user_id: String, diff --git a/server-rs/crates/spacetime-client/src/auth.rs b/server-rs/crates/spacetime-client/src/auth.rs index 89f14e61a..9d55dac1e 100644 --- a/server-rs/crates/spacetime-client/src/auth.rs +++ b/server-rs/crates/spacetime-client/src/auth.rs @@ -1,6 +1,30 @@ use super::*; impl SpacetimeClient { + pub async fn validate_auth_session( + &self, + input: AuthSessionValidationRecordInput, + ) -> Result { + let procedure_input = crate::module_bindings::AuthSessionValidationInput { + user_id: input.user_id, + session_id: input.session_id, + token_version: input.token_version, + }; + + self.call_after_connect("validate_auth_session", move |connection, sender| { + connection.procedures().validate_auth_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_auth_session_validation_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + pub async fn export_auth_store_projection_from_tables( &self, ) -> Result { diff --git a/server-rs/crates/spacetime-client/src/mapper/auth.rs b/server-rs/crates/spacetime-client/src/mapper/auth.rs index e0ba264bf..0e0c7b5ac 100644 --- a/server-rs/crates/spacetime-client/src/mapper/auth.rs +++ b/server-rs/crates/spacetime-client/src/mapper/auth.rs @@ -35,6 +35,7 @@ pub(crate) fn map_auth_store_projection_view_input( view: module_auth::AuthStoreProjectionView, ) -> crate::module_bindings::AuthStoreProjectionView { crate::module_bindings::AuthStoreProjectionView { + base_updated_at_micros: view.base_updated_at_micros, updated_at_micros: view.updated_at_micros, users: view .users @@ -87,6 +88,35 @@ pub(crate) fn map_auth_store_projection_view_input( }, ) .collect(), + phone_codes: view + .phone_codes + .into_iter() + .map(|code| crate::module_bindings::AuthStoreProjectionPhoneCode { + phone_number: code.phone_number, + scene: code.scene, + verify_code_hash: code.verify_code_hash, + expires_at: code.expires_at, + last_sent_at: code.last_sent_at, + failed_attempts: code.failed_attempts, + provider_out_id: code.provider_out_id, + }) + .collect(), + wechat_states: view + .wechat_states + .into_iter() + .map(|state| crate::module_bindings::AuthStoreProjectionWechatState { + wechat_state_id: state.wechat_state_id, + state_token: state.state_token, + redirect_path: state.redirect_path, + scene: state.scene, + request_user_agent: state.request_user_agent, + bind_user_id: state.bind_user_id, + expires_at: state.expires_at, + consumed_at: state.consumed_at, + created_at: state.created_at, + updated_at: state.updated_at, + }) + .collect(), } } @@ -94,6 +124,7 @@ fn map_auth_store_projection_view( view: crate::module_bindings::AuthStoreProjectionView, ) -> module_auth::AuthStoreProjectionView { module_auth::AuthStoreProjectionView { + base_updated_at_micros: view.base_updated_at_micros, updated_at_micros: view.updated_at_micros, users: view .users @@ -142,5 +173,34 @@ fn map_auth_store_projection_view( last_seen_at: session.last_seen_at, }) .collect(), + phone_codes: view + .phone_codes + .into_iter() + .map(|code| module_auth::AuthStoreProjectionPhoneCode { + phone_number: code.phone_number, + scene: code.scene, + verify_code_hash: code.verify_code_hash, + expires_at: code.expires_at, + last_sent_at: code.last_sent_at, + failed_attempts: code.failed_attempts, + provider_out_id: code.provider_out_id, + }) + .collect(), + wechat_states: view + .wechat_states + .into_iter() + .map(|state| module_auth::AuthStoreProjectionWechatState { + wechat_state_id: state.wechat_state_id, + state_token: state.state_token, + redirect_path: state.redirect_path, + scene: state.scene, + request_user_agent: state.request_user_agent, + bind_user_id: state.bind_user_id, + expires_at: state.expires_at, + consumed_at: state.consumed_at, + created_at: state.created_at, + updated_at: state.updated_at, + }) + .collect(), } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index b9e6e2dad..f6c9c3b9d 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.7.0 (commit d220349adb7af7eefa810eb08a185609356b83f6). +// This was generated using spacetimedb cli version 2.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -115,15 +115,19 @@ pub mod asset_operation_wallet_settlement_type; pub mod attach_ai_result_reference_and_return_procedure; pub mod auth_identity_table; pub mod auth_identity_type; +pub mod auth_session_validation_input_type; +pub mod auth_session_validation_procedure_result_type; pub mod auth_store_projection_identity_type; pub mod auth_store_projection_meta_table; pub mod auth_store_projection_meta_type; +pub mod auth_store_projection_phone_code_type; pub mod auth_store_projection_procedure_result_type; pub mod auth_store_projection_refresh_session_type; pub mod auth_store_projection_sync_procedure_result_type; pub mod auth_store_projection_sync_record_type; pub mod auth_store_projection_user_type; pub mod auth_store_projection_view_type; +pub mod auth_store_projection_wechat_state_type; pub mod authenticate_external_api_key_and_return_procedure; pub mod authorize_database_migration_operator_procedure; pub mod backfill_editor_canvas_layout_and_return_procedure; @@ -379,6 +383,7 @@ pub mod editor_spritesheet_slice_batch_persist_result_type; pub mod editor_spritesheet_slice_persist_item_input_type; pub mod editor_spritesheet_slice_persisted_item_type; pub mod enqueue_external_generation_job_and_return_procedure; +pub mod enqueue_profile_wallet_refund_outbox_and_return_procedure; pub mod ensure_analytics_date_dimension_for_date_reducer; pub mod expire_profile_recharge_order_timer_reducer; pub mod export_auth_store_projection_from_tables_procedure; @@ -409,6 +414,8 @@ pub mod external_generation_job_procedure_result_type; pub mod external_generation_job_renew_lease_input_type; pub mod external_generation_job_result_procedure_result_type; pub mod external_generation_job_result_snapshot_type; +pub mod external_generation_job_retention_input_type; +pub mod external_generation_job_retention_procedure_result_type; pub mod external_generation_job_snapshot_type; pub mod external_generation_job_summary_backfill_input_type; pub mod external_generation_job_summary_backfill_procedure_result_type; @@ -516,6 +523,7 @@ pub mod preflight_editor_generation_target_and_return_procedure; pub mod preflight_editor_pixel_art_result_and_return_procedure; pub mod prepare_profile_recharge_refund_hold_and_return_procedure; pub mod preview_profile_recharge_refund_hold_and_return_procedure; +pub mod process_profile_wallet_refund_outbox_and_return_procedure; pub mod profile_code_operation_table; pub mod profile_code_operation_type; pub mod profile_daily_free_points_table; @@ -570,6 +578,9 @@ pub mod profile_wallet_ledger_table; pub mod profile_wallet_ledger_type; pub mod profile_wallet_manual_restriction_table; pub mod profile_wallet_manual_restriction_type; +pub mod profile_wallet_refund_outbox_table; +pub mod profile_wallet_refund_outbox_type; +pub mod prune_external_generation_job_history_and_return_procedure; pub mod public_work_like_table; pub mod public_work_like_type; pub mod public_work_play_daily_stat_table; @@ -778,6 +789,9 @@ pub mod runtime_profile_wallet_ledger_procedure_result_type; pub mod runtime_profile_wallet_ledger_source_type_type; pub mod runtime_profile_wallet_manual_restriction_snapshot_type; pub mod runtime_profile_wallet_manual_restriction_upsert_input_type; +pub mod runtime_profile_wallet_refund_outbox_enqueue_input_type; +pub mod runtime_profile_wallet_refund_outbox_procedure_result_type; +pub mod runtime_profile_wallet_refund_outbox_process_input_type; pub mod runtime_referral_invite_center_get_input_type; pub mod runtime_referral_invite_center_procedure_result_type; pub mod runtime_referral_invite_center_snapshot_type; @@ -844,6 +858,7 @@ pub mod user_account_table; pub mod user_account_type; pub mod user_browse_history_table; pub mod user_browse_history_type; +pub mod validate_auth_session_procedure; pub mod visual_novel_agent_message_row_type; pub mod visual_novel_agent_message_table; pub mod visual_novel_agent_session_row_type; @@ -974,15 +989,19 @@ pub use asset_operation_wallet_settlement_type::AssetOperationWalletSettlement; pub use attach_ai_result_reference_and_return_procedure::attach_ai_result_reference_and_return; pub use auth_identity_table::*; pub use auth_identity_type::AuthIdentity; +pub use auth_session_validation_input_type::AuthSessionValidationInput; +pub use auth_session_validation_procedure_result_type::AuthSessionValidationProcedureResult; pub use auth_store_projection_identity_type::AuthStoreProjectionIdentity; pub use auth_store_projection_meta_table::*; pub use auth_store_projection_meta_type::AuthStoreProjectionMeta; +pub use auth_store_projection_phone_code_type::AuthStoreProjectionPhoneCode; pub use auth_store_projection_procedure_result_type::AuthStoreProjectionProcedureResult; pub use auth_store_projection_refresh_session_type::AuthStoreProjectionRefreshSession; pub use auth_store_projection_sync_procedure_result_type::AuthStoreProjectionSyncProcedureResult; pub use auth_store_projection_sync_record_type::AuthStoreProjectionSyncRecord; pub use auth_store_projection_user_type::AuthStoreProjectionUser; pub use auth_store_projection_view_type::AuthStoreProjectionView; +pub use auth_store_projection_wechat_state_type::AuthStoreProjectionWechatState; pub use authenticate_external_api_key_and_return_procedure::authenticate_external_api_key_and_return; pub use authorize_database_migration_operator_procedure::authorize_database_migration_operator; pub use backfill_editor_canvas_layout_and_return_procedure::backfill_editor_canvas_layout_and_return; @@ -1238,6 +1257,7 @@ pub use editor_spritesheet_slice_batch_persist_result_type::EditorSpritesheetSli pub use editor_spritesheet_slice_persist_item_input_type::EditorSpritesheetSlicePersistItemInput; pub use editor_spritesheet_slice_persisted_item_type::EditorSpritesheetSlicePersistedItem; pub use enqueue_external_generation_job_and_return_procedure::enqueue_external_generation_job_and_return; +pub use enqueue_profile_wallet_refund_outbox_and_return_procedure::enqueue_profile_wallet_refund_outbox_and_return; pub use ensure_analytics_date_dimension_for_date_reducer::ensure_analytics_date_dimension_for_date; pub use expire_profile_recharge_order_timer_reducer::expire_profile_recharge_order_timer; pub use export_auth_store_projection_from_tables_procedure::export_auth_store_projection_from_tables; @@ -1268,6 +1288,8 @@ pub use external_generation_job_procedure_result_type::ExternalGenerationJobProc pub use external_generation_job_renew_lease_input_type::ExternalGenerationJobRenewLeaseInput; pub use external_generation_job_result_procedure_result_type::ExternalGenerationJobResultProcedureResult; pub use external_generation_job_result_snapshot_type::ExternalGenerationJobResultSnapshot; +pub use external_generation_job_retention_input_type::ExternalGenerationJobRetentionInput; +pub use external_generation_job_retention_procedure_result_type::ExternalGenerationJobRetentionProcedureResult; pub use external_generation_job_snapshot_type::ExternalGenerationJobSnapshot; pub use external_generation_job_summary_backfill_input_type::ExternalGenerationJobSummaryBackfillInput; pub use external_generation_job_summary_backfill_procedure_result_type::ExternalGenerationJobSummaryBackfillProcedureResult; @@ -1375,6 +1397,7 @@ pub use preflight_editor_generation_target_and_return_procedure::preflight_edito pub use preflight_editor_pixel_art_result_and_return_procedure::preflight_editor_pixel_art_result_and_return; pub use prepare_profile_recharge_refund_hold_and_return_procedure::prepare_profile_recharge_refund_hold_and_return; pub use preview_profile_recharge_refund_hold_and_return_procedure::preview_profile_recharge_refund_hold_and_return; +pub use process_profile_wallet_refund_outbox_and_return_procedure::process_profile_wallet_refund_outbox_and_return; pub use profile_code_operation_table::*; pub use profile_code_operation_type::ProfileCodeOperation; pub use profile_daily_free_points_table::*; @@ -1429,6 +1452,9 @@ pub use profile_wallet_ledger_table::*; pub use profile_wallet_ledger_type::ProfileWalletLedger; pub use profile_wallet_manual_restriction_table::*; pub use profile_wallet_manual_restriction_type::ProfileWalletManualRestriction; +pub use profile_wallet_refund_outbox_table::*; +pub use profile_wallet_refund_outbox_type::ProfileWalletRefundOutbox; +pub use prune_external_generation_job_history_and_return_procedure::prune_external_generation_job_history_and_return; pub use public_work_like_table::*; pub use public_work_like_type::PublicWorkLike; pub use public_work_play_daily_stat_table::*; @@ -1637,6 +1663,9 @@ pub use runtime_profile_wallet_ledger_procedure_result_type::RuntimeProfileWalle pub use runtime_profile_wallet_ledger_source_type_type::RuntimeProfileWalletLedgerSourceType; pub use runtime_profile_wallet_manual_restriction_snapshot_type::RuntimeProfileWalletManualRestrictionSnapshot; pub use runtime_profile_wallet_manual_restriction_upsert_input_type::RuntimeProfileWalletManualRestrictionUpsertInput; +pub use runtime_profile_wallet_refund_outbox_enqueue_input_type::RuntimeProfileWalletRefundOutboxEnqueueInput; +pub use runtime_profile_wallet_refund_outbox_procedure_result_type::RuntimeProfileWalletRefundOutboxProcedureResult; +pub use runtime_profile_wallet_refund_outbox_process_input_type::RuntimeProfileWalletRefundOutboxProcessInput; pub use runtime_referral_invite_center_get_input_type::RuntimeReferralInviteCenterGetInput; pub use runtime_referral_invite_center_procedure_result_type::RuntimeReferralInviteCenterProcedureResult; pub use runtime_referral_invite_center_snapshot_type::RuntimeReferralInviteCenterSnapshot; @@ -1703,6 +1732,7 @@ pub use user_account_table::*; pub use user_account_type::UserAccount; pub use user_browse_history_table::*; pub use user_browse_history_type::UserBrowseHistory; +pub use validate_auth_session_procedure::validate_auth_session; pub use visual_novel_agent_message_row_type::VisualNovelAgentMessageRow; pub use visual_novel_agent_message_table::*; pub use visual_novel_agent_session_row_type::VisualNovelAgentSessionRow; @@ -1937,6 +1967,7 @@ pub struct DbUpdate { profile_wallet_consumption_total: __sdk::TableUpdate, profile_wallet_ledger: __sdk::TableUpdate, profile_wallet_manual_restriction: __sdk::TableUpdate, + profile_wallet_refund_outbox: __sdk::TableUpdate, public_work_like: __sdk::TableUpdate, public_work_play_daily_stat: __sdk::TableUpdate, puzzle_agent_message: __sdk::TableUpdate, @@ -2340,6 +2371,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { profile_wallet_manual_restriction_table::parse_table_update(table_update)?, ) } + "profile_wallet_refund_outbox" => db_update.profile_wallet_refund_outbox.append( + profile_wallet_refund_outbox_table::parse_table_update(table_update)?, + ), "public_work_like" => db_update .public_work_like .append(public_work_like_table::parse_table_update(table_update)?), @@ -3030,6 +3064,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.profile_wallet_manual_restriction, ) .with_updates_by_pk(|row| &row.user_id); + diff.profile_wallet_refund_outbox = cache + .apply_diff_to_table::( + "profile_wallet_refund_outbox", + &self.profile_wallet_refund_outbox, + ) + .with_updates_by_pk(|row| &row.refund_ledger_id); diff.public_work_like = cache .apply_diff_to_table::("public_work_like", &self.public_work_like) .with_updates_by_pk(|row| &row.like_id); @@ -3525,6 +3565,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_wallet_manual_restriction" => db_update .profile_wallet_manual_restriction .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_wallet_refund_outbox" => db_update + .profile_wallet_refund_outbox + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "public_work_like" => db_update .public_work_like .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3955,6 +3998,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_wallet_manual_restriction" => db_update .profile_wallet_manual_restriction .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_wallet_refund_outbox" => db_update + .profile_wallet_refund_outbox + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "public_work_like" => db_update .public_work_like .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -4196,6 +4242,7 @@ pub struct AppliedDiff<'r> { profile_wallet_consumption_total: __sdk::TableAppliedDiff<'r, ProfileWalletConsumptionTotal>, profile_wallet_ledger: __sdk::TableAppliedDiff<'r, ProfileWalletLedger>, profile_wallet_manual_restriction: __sdk::TableAppliedDiff<'r, ProfileWalletManualRestriction>, + profile_wallet_refund_outbox: __sdk::TableAppliedDiff<'r, ProfileWalletRefundOutbox>, public_work_like: __sdk::TableAppliedDiff<'r, PublicWorkLike>, public_work_play_daily_stat: __sdk::TableAppliedDiff<'r, PublicWorkPlayDailyStat>, puzzle_agent_message: __sdk::TableAppliedDiff<'r, PuzzleAgentMessageRow>, @@ -4737,6 +4784,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.profile_wallet_manual_restriction, event, ); + callbacks.invoke_table_row_callbacks::( + "profile_wallet_refund_outbox", + &self.profile_wallet_refund_outbox, + event, + ); callbacks.invoke_table_row_callbacks::( "public_work_like", &self.public_work_like, @@ -5684,6 +5736,7 @@ impl __sdk::SpacetimeModule for RemoteModule { profile_wallet_consumption_total_table::register_table(client_cache); profile_wallet_ledger_table::register_table(client_cache); profile_wallet_manual_restriction_table::register_table(client_cache); + profile_wallet_refund_outbox_table::register_table(client_cache); public_work_like_table::register_table(client_cache); public_work_play_daily_stat_table::register_table(client_cache); puzzle_agent_message_table::register_table(client_cache); @@ -5825,6 +5878,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "profile_wallet_consumption_total", "profile_wallet_ledger", "profile_wallet_manual_restriction", + "profile_wallet_refund_outbox", "public_work_like", "public_work_play_daily_stat", "puzzle_agent_message", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_external_generation_job_summaries_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_external_generation_job_summaries_and_return_procedure.rs index 5ef0251b0..48ec50384 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_external_generation_job_summaries_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_external_generation_job_summaries_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait acknowledge_external_generation_job_summaries_and_return { input: ExternalGenerationJobAcknowledgeInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl acknowledge_external_generation_job_summaries_and_return for super::RemoteP input: ExternalGenerationJobAcknowledgeInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalGenerationJobSummaryProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_external_generation_jobs_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_external_generation_jobs_and_return_procedure.rs index e9b1c18fe..4c83af9ca 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_external_generation_jobs_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_external_generation_jobs_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait acknowledge_external_generation_jobs_and_return { input: ExternalGenerationJobAcknowledgeInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl acknowledge_external_generation_jobs_and_return for super::RemoteProcedures input: ExternalGenerationJobAcknowledgeInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/activate_editor_canvas_layout_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/activate_editor_canvas_layout_and_return_procedure.rs index b9c3aa0ba..a92a1ed66 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/activate_editor_canvas_layout_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/activate_editor_canvas_layout_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait activate_editor_canvas_layout_and_return { input: EditorCanvasLayoutMigrationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl activate_editor_canvas_layout_and_return for super::RemoteProcedures { input: EditorCanvasLayoutMigrationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorCanvasLayoutMigrationProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs index bbdaab4f3..9865ace59 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs @@ -31,10 +31,10 @@ pub trait admin_disable_profile_redeem_code { input: RuntimeProfileRedeemCodeAdminDisableInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl admin_disable_profile_redeem_code for super::RemoteProcedures { input: RuntimeProfileRedeemCodeAdminDisableInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_task_config_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_task_config_procedure.rs index c968f950d..0417bd2e9 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_task_config_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_task_config_procedure.rs @@ -31,10 +31,10 @@ pub trait admin_disable_profile_task_config { input: RuntimeProfileTaskConfigAdminDisableInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl admin_disable_profile_task_config for super::RemoteProcedures { input: RuntimeProfileTaskConfigAdminDisableInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_get_profile_wallet_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_get_profile_wallet_and_return_procedure.rs index b482cdd15..782d73f58 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_get_profile_wallet_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_get_profile_wallet_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait admin_get_profile_wallet_and_return { input: RuntimeProfileAdminWalletGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl admin_get_profile_wallet_and_return for super::RemoteProcedures { input: RuntimeProfileAdminWalletGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileAdminWalletProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_get_profile_wallet_config_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_get_profile_wallet_config_procedure.rs index c7c836f1b..1f48e0770 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_get_profile_wallet_config_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_get_profile_wallet_config_procedure.rs @@ -31,10 +31,10 @@ pub trait admin_get_profile_wallet_config { input: RuntimeProfileWalletConfigAdminGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl admin_get_profile_wallet_config for super::RemoteProcedures { input: RuntimeProfileWalletConfigAdminGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileWalletConfigAdminProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_get_profile_wallet_detail_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_get_profile_wallet_detail_and_return_procedure.rs index 95f78510c..bacc1bbb4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_get_profile_wallet_detail_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_get_profile_wallet_detail_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait admin_get_profile_wallet_detail_and_return { input: RuntimeProfileAdminWalletGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl admin_get_profile_wallet_detail_and_return for super::RemoteProcedures { input: RuntimeProfileAdminWalletGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileAdminWalletDetailProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_initialize_profile_wallet_consumption_projections_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_initialize_profile_wallet_consumption_projections_and_return_procedure.rs index 02fa9d1f6..9c5414c9c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_initialize_profile_wallet_consumption_projections_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_initialize_profile_wallet_consumption_projections_and_return_procedure.rs @@ -37,13 +37,13 @@ pub trait admin_initialize_profile_wallet_consumption_projections_and_return { input: RuntimeProfileWalletConsumptionProjectionInitializeInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result< - RuntimeProfileWalletConsumptionProjectionInitializeProcedureResult, - __sdk::InternalError, - >, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result< + RuntimeProfileWalletConsumptionProjectionInitializeProcedureResult, + __sdk::InternalError, + >, + ) + Send + + 'static, ); } @@ -55,13 +55,13 @@ impl admin_initialize_profile_wallet_consumption_projections_and_return input: RuntimeProfileWalletConsumptionProjectionInitializeInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result< - RuntimeProfileWalletConsumptionProjectionInitializeProcedureResult, - __sdk::InternalError, - >, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result< + RuntimeProfileWalletConsumptionProjectionInitializeProcedureResult, + __sdk::InternalError, + >, + ) + Send + + 'static, ) { self.imp.invoke_procedure_with_callback::<_, RuntimeProfileWalletConsumptionProjectionInitializeProcedureResult>( "admin_initialize_profile_wallet_consumption_projections_and_return", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_editor_assets_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_editor_assets_and_return_procedure.rs index cc3c8ecd6..8e3589adb 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_editor_assets_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_editor_assets_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait admin_list_editor_assets_and_return { input: AdminEditorAssetListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl admin_list_editor_assets_and_return for super::RemoteProcedures { input: AdminEditorAssetListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AdminEditorAssetListProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_editor_showcase_assets_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_editor_showcase_assets_and_return_procedure.rs index 69ce4beca..a740930a7 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_editor_showcase_assets_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_editor_showcase_assets_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait admin_list_editor_showcase_assets_and_return { input: EditorShowcaseAssetAdminListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl admin_list_editor_showcase_assets_and_return for super::RemoteProcedures { input: EditorShowcaseAssetAdminListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorShowcaseAssetListProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_invite_codes_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_invite_codes_procedure.rs index cdfa27d91..96d2350f5 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_invite_codes_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_invite_codes_procedure.rs @@ -31,10 +31,10 @@ pub trait admin_list_profile_invite_codes { input: RuntimeProfileInviteCodeAdminListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl admin_list_profile_invite_codes for super::RemoteProcedures { input: RuntimeProfileInviteCodeAdminListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileInviteCodeAdminListProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_orders_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_orders_and_return_procedure.rs index d0c49b1a7..f0238f7a0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_orders_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_orders_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait admin_list_profile_recharge_orders_and_return { input: RuntimeProfileRechargeOrderAdminListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl admin_list_profile_recharge_orders_and_return for super::RemoteProcedures { input: RuntimeProfileRechargeOrderAdminListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeOrderAdminListProcedureResult>( "admin_list_profile_recharge_orders_and_return", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_products_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_products_procedure.rs index e84d4ec62..a1deed886 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_products_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_products_procedure.rs @@ -34,10 +34,10 @@ pub trait admin_list_profile_recharge_products { input: RuntimeProfileRechargeProductAdminListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl admin_list_profile_recharge_products for super::RemoteProcedures { input: RuntimeProfileRechargeProductAdminListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeProductAdminListProcedureResult>( "admin_list_profile_recharge_products", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_redeem_codes_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_redeem_codes_procedure.rs index 2c9b9dd73..c7d6a78e8 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_redeem_codes_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_redeem_codes_procedure.rs @@ -31,10 +31,10 @@ pub trait admin_list_profile_redeem_codes { input: RuntimeProfileRedeemCodeAdminListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl admin_list_profile_redeem_codes for super::RemoteProcedures { input: RuntimeProfileRedeemCodeAdminListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminListProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_task_configs_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_task_configs_procedure.rs index 88ca28d55..a152116df 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_task_configs_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_task_configs_procedure.rs @@ -31,10 +31,10 @@ pub trait admin_list_profile_task_configs { input: RuntimeProfileTaskConfigAdminListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl admin_list_profile_task_configs for super::RemoteProcedures { input: RuntimeProfileTaskConfigAdminListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminListProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_reconcile_profile_wallet_consumption_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_reconcile_profile_wallet_consumption_and_return_procedure.rs index f267d78af..1002fcc4e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_reconcile_profile_wallet_consumption_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_reconcile_profile_wallet_consumption_and_return_procedure.rs @@ -34,13 +34,10 @@ pub trait admin_reconcile_profile_wallet_consumption_and_return { input: RuntimeProfileWalletConsumptionReconcileInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result< - RuntimeProfileWalletConsumptionReconcileProcedureResult, - __sdk::InternalError, - >, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -50,13 +47,10 @@ impl admin_reconcile_profile_wallet_consumption_and_return for super::RemoteProc input: RuntimeProfileWalletConsumptionReconcileInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result< - RuntimeProfileWalletConsumptionReconcileProcedureResult, - __sdk::InternalError, - >, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp.invoke_procedure_with_callback::<_, RuntimeProfileWalletConsumptionReconcileProcedureResult>( "admin_reconcile_profile_wallet_consumption_and_return", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_review_editor_showcase_asset_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_review_editor_showcase_asset_and_return_procedure.rs index 29b2ecf60..71334fc07 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_review_editor_showcase_asset_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_review_editor_showcase_asset_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait admin_review_editor_showcase_asset_and_return { input: EditorShowcaseAssetAdminReviewInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl admin_review_editor_showcase_asset_and_return for super::RemoteProcedures { input: EditorShowcaseAssetAdminReviewInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorShowcaseAssetProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs index 3601be97f..2411092d2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs @@ -31,10 +31,10 @@ pub trait admin_upsert_profile_invite_code { input: RuntimeProfileInviteCodeAdminUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl admin_upsert_profile_invite_code for super::RemoteProcedures { input: RuntimeProfileInviteCodeAdminUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileInviteCodeAdminProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_recharge_product_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_recharge_product_procedure.rs index e3f42278b..83941b836 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_recharge_product_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_recharge_product_procedure.rs @@ -34,10 +34,10 @@ pub trait admin_upsert_profile_recharge_product { input: RuntimeProfileRechargeProductAdminUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl admin_upsert_profile_recharge_product for super::RemoteProcedures { input: RuntimeProfileRechargeProductAdminUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRechargeProductAdminProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs index 7e918220f..9c7ae92f1 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs @@ -31,10 +31,10 @@ pub trait admin_upsert_profile_redeem_code { input: RuntimeProfileRedeemCodeAdminUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl admin_upsert_profile_redeem_code for super::RemoteProcedures { input: RuntimeProfileRedeemCodeAdminUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_task_config_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_task_config_procedure.rs index a3d3e11a4..b441a8084 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_task_config_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_task_config_procedure.rs @@ -31,10 +31,10 @@ pub trait admin_upsert_profile_task_config { input: RuntimeProfileTaskConfigAdminUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl admin_upsert_profile_task_config for super::RemoteProcedures { input: RuntimeProfileTaskConfigAdminUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_wallet_config_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_wallet_config_procedure.rs index b87b6506d..46814d669 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_wallet_config_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_wallet_config_procedure.rs @@ -34,10 +34,10 @@ pub trait admin_upsert_profile_wallet_config { input: RuntimeProfileWalletConfigAdminUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl admin_upsert_profile_wallet_config for super::RemoteProcedures { input: RuntimeProfileWalletConfigAdminUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileWalletConfigAdminProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_wallet_manual_restriction_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_wallet_manual_restriction_and_return_procedure.rs index 54786f87a..c313d2d44 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_wallet_manual_restriction_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_wallet_manual_restriction_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait admin_upsert_profile_wallet_manual_restriction_and_return { input: RuntimeProfileWalletManualRestrictionUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl admin_upsert_profile_wallet_manual_restriction_and_return for super::Remote input: RuntimeProfileWalletManualRestrictionUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileAdminWalletProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/advance_profile_recharge_refund_bill_checkpoint_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/advance_profile_recharge_refund_bill_checkpoint_and_return_procedure.rs index 064148be6..06631fcfe 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/advance_profile_recharge_refund_bill_checkpoint_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/advance_profile_recharge_refund_bill_checkpoint_and_return_procedure.rs @@ -34,13 +34,10 @@ pub trait advance_profile_recharge_refund_bill_checkpoint_and_return { input: RuntimeProfileRechargeRefundBillCheckpointAdvanceInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result< - RuntimeProfileRechargeRefundBillCheckpointProcedureResult, - __sdk::InternalError, - >, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -50,13 +47,10 @@ impl advance_profile_recharge_refund_bill_checkpoint_and_return for super::Remot input: RuntimeProfileRechargeRefundBillCheckpointAdvanceInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result< - RuntimeProfileRechargeRefundBillCheckpointProcedureResult, - __sdk::InternalError, - >, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundBillCheckpointProcedureResult>( "advance_profile_recharge_refund_bill_checkpoint_and_return", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs index 11323392d..191e2ea7c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait append_ai_text_chunk_and_return { input: AiTextChunkAppendInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl append_ai_text_chunk_and_return for super::RemoteProcedures { input: AiTextChunkAppendInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AiTaskProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs index 2f3edbe2a..94d418502 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait attach_ai_result_reference_and_return { input: AiResultReferenceInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl attach_ai_result_reference_and_return for super::RemoteProcedures { input: AiResultReferenceInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AiTaskProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_session_validation_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_session_validation_input_type.rs new file mode 100644 index 000000000..2d1479970 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_session_validation_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 AuthSessionValidationInput { + pub user_id: String, + pub session_id: String, + pub token_version: u64, +} + +impl __sdk::InModule for AuthSessionValidationInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_session_validation_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_session_validation_procedure_result_type.rs new file mode 100644 index 000000000..5e9afc3d5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_session_validation_procedure_result_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 AuthSessionValidationProcedureResult { + pub active: bool, + pub error_message: Option, +} + +impl __sdk::InModule for AuthSessionValidationProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_meta_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_meta_type.rs index 309dceff7..2e7b2d303 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_meta_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_meta_type.rs @@ -9,6 +9,8 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; pub struct AuthStoreProjectionMeta { pub meta_id: String, pub updated_at: __sdk::Timestamp, + pub phone_codes_json: Option, + pub wechat_states_json: Option, } impl __sdk::InModule for AuthStoreProjectionMeta { @@ -21,6 +23,8 @@ impl __sdk::InModule for AuthStoreProjectionMeta { pub struct AuthStoreProjectionMetaCols { pub meta_id: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub phone_codes_json: __sdk::__query_builder::Col>, + pub wechat_states_json: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for AuthStoreProjectionMeta { @@ -29,6 +33,8 @@ impl __sdk::__query_builder::HasCols for AuthStoreProjectionMeta { AuthStoreProjectionMetaCols { meta_id: __sdk::__query_builder::Col::new(table_name, "meta_id"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + phone_codes_json: __sdk::__query_builder::Col::new(table_name, "phone_codes_json"), + wechat_states_json: __sdk::__query_builder::Col::new(table_name, "wechat_states_json"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_phone_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_phone_code_type.rs new file mode 100644 index 000000000..10c7bffc2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_phone_code_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 AuthStoreProjectionPhoneCode { + pub phone_number: String, + pub scene: String, + pub verify_code_hash: String, + pub expires_at: String, + pub last_sent_at: String, + pub failed_attempts: u32, + pub provider_out_id: Option, +} + +impl __sdk::InModule for AuthStoreProjectionPhoneCode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_view_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_view_type.rs index fb99a128c..fa743157a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_view_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_view_type.rs @@ -5,8 +5,10 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use super::auth_store_projection_identity_type::AuthStoreProjectionIdentity; +use super::auth_store_projection_phone_code_type::AuthStoreProjectionPhoneCode; use super::auth_store_projection_refresh_session_type::AuthStoreProjectionRefreshSession; use super::auth_store_projection_user_type::AuthStoreProjectionUser; +use super::auth_store_projection_wechat_state_type::AuthStoreProjectionWechatState; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] @@ -15,6 +17,9 @@ pub struct AuthStoreProjectionView { pub users: Vec, pub identities: Vec, pub refresh_sessions: Vec, + pub phone_codes: Vec, + pub wechat_states: Vec, + pub base_updated_at_micros: i64, } impl __sdk::InModule for AuthStoreProjectionView { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_wechat_state_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_wechat_state_type.rs new file mode 100644 index 000000000..7de462fa8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_wechat_state_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 AuthStoreProjectionWechatState { + pub wechat_state_id: String, + pub state_token: String, + pub redirect_path: String, + pub scene: String, + pub request_user_agent: Option, + pub bind_user_id: Option, + pub expires_at: String, + pub consumed_at: Option, + pub created_at: String, + pub updated_at: String, +} + +impl __sdk::InModule for AuthStoreProjectionWechatState { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/authenticate_external_api_key_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/authenticate_external_api_key_and_return_procedure.rs index 4c3aec34c..279534f41 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/authenticate_external_api_key_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/authenticate_external_api_key_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait authenticate_external_api_key_and_return { input: ExternalApiKeyAuthenticateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl authenticate_external_api_key_and_return for super::RemoteProcedures { input: ExternalApiKeyAuthenticateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalApiKeyProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/authorize_database_migration_operator_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/authorize_database_migration_operator_procedure.rs index b58850228..ac77f7e80 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/authorize_database_migration_operator_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/authorize_database_migration_operator_procedure.rs @@ -34,10 +34,10 @@ pub trait authorize_database_migration_operator { input: DatabaseMigrationAuthorizeOperatorInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl authorize_database_migration_operator for super::RemoteProcedures { input: DatabaseMigrationAuthorizeOperatorInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, DatabaseMigrationOperatorProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/backfill_editor_canvas_layout_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/backfill_editor_canvas_layout_and_return_procedure.rs index 9af4d59d3..714b64947 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/backfill_editor_canvas_layout_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/backfill_editor_canvas_layout_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait backfill_editor_canvas_layout_and_return { input: EditorCanvasLayoutMigrationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl backfill_editor_canvas_layout_and_return for super::RemoteProcedures { input: EditorCanvasLayoutMigrationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorCanvasLayoutMigrationProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/backfill_external_generation_job_summaries_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/backfill_external_generation_job_summaries_and_return_procedure.rs index 8636a1587..315b908ab 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/backfill_external_generation_job_summaries_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/backfill_external_generation_job_summaries_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait backfill_external_generation_job_summaries_and_return { input: ExternalGenerationJobSummaryBackfillInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl backfill_external_generation_job_summaries_and_return for super::RemoteProc input: ExternalGenerationJobSummaryBackfillInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp.invoke_procedure_with_callback::<_, ExternalGenerationJobSummaryBackfillProcedureResult>( "backfill_external_generation_job_summaries_and_return", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs index b709d5c2f..78c80aee5 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait bind_asset_object_to_entity_and_return { input: AssetEntityBindingInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl bind_asset_object_to_entity_and_return for super::RemoteProcedures { input: AssetEntityBindingInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AssetEntityBindingProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_reducer.rs index b20bc5b23..caf48b269 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_reducer.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_reducer.rs @@ -47,9 +47,11 @@ pub trait bind_asset_object_to_entity { &self, input: AssetEntityBindingInput, - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) -> __sdk::Result<()>; } @@ -58,9 +60,11 @@ impl bind_asset_object_to_entity for super::RemoteReducers { &self, input: AssetEntityBindingInput, - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) -> __sdk::Result<()> { self.imp .invoke_reducer_with_callback(BindAssetObjectToEntityArgs { input }, callback) diff --git a/server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs index b239e0600..0c5dc3eb4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait cancel_ai_task_and_return { input: AiTaskCancelInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl cancel_ai_task_and_return for super::RemoteProcedures { input: AiTaskCancelInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AiTaskProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/claim_external_generation_jobs_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/claim_external_generation_jobs_and_return_procedure.rs index 6455c7b2e..979ea56cc 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/claim_external_generation_jobs_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/claim_external_generation_jobs_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait claim_external_generation_jobs_and_return { input: ExternalGenerationJobClaimInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl claim_external_generation_jobs_and_return for super::RemoteProcedures { input: ExternalGenerationJobClaimInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/claim_profile_recharge_order_expiration_schedule_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/claim_profile_recharge_order_expiration_schedule_and_return_procedure.rs index 93a626b00..93d75e737 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/claim_profile_recharge_order_expiration_schedule_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/claim_profile_recharge_order_expiration_schedule_and_return_procedure.rs @@ -34,13 +34,10 @@ pub trait claim_profile_recharge_order_expiration_schedule_and_return { input: RuntimeProfileRechargeOrderExpirationClaimInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result< - RuntimeProfileRechargeOrderExpirationClaimProcedureResult, - __sdk::InternalError, - >, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -50,13 +47,10 @@ impl claim_profile_recharge_order_expiration_schedule_and_return for super::Remo input: RuntimeProfileRechargeOrderExpirationClaimInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result< - RuntimeProfileRechargeOrderExpirationClaimProcedureResult, - __sdk::InternalError, - >, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeOrderExpirationClaimProcedureResult>( "claim_profile_recharge_order_expiration_schedule_and_return", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/claim_profile_task_reward_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/claim_profile_task_reward_and_return_procedure.rs index 5a386f3cd..ea5010707 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/claim_profile_task_reward_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/claim_profile_task_reward_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait claim_profile_task_reward_and_return { input: RuntimeProfileTaskClaimInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl claim_profile_task_reward_and_return for super::RemoteProcedures { input: RuntimeProfileTaskClaimInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileTaskClaimProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/clean_editor_image_asset_kind_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/clean_editor_image_asset_kind_and_return_procedure.rs index 1241d9343..a32372261 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/clean_editor_image_asset_kind_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/clean_editor_image_asset_kind_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait clean_editor_image_asset_kind_and_return { input: EditorImageAssetKindCleanupInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl clean_editor_image_asset_kind_and_return for super::RemoteProcedures { input: EditorImageAssetKindCleanupInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorImageAssetKindCleanupProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/clear_database_migration_import_chunks_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/clear_database_migration_import_chunks_procedure.rs index 51146e998..d05fcdf27 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/clear_database_migration_import_chunks_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/clear_database_migration_import_chunks_procedure.rs @@ -34,10 +34,10 @@ pub trait clear_database_migration_import_chunks { input: DatabaseMigrationImportChunksClearInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl clear_database_migration_import_chunks for super::RemoteProcedures { input: DatabaseMigrationImportChunksClearInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/close_profile_recharge_order_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/close_profile_recharge_order_and_return_procedure.rs index 461f43399..984e99294 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/close_profile_recharge_order_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/close_profile_recharge_order_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait close_profile_recharge_order_and_return { input: RuntimeProfileRechargeOrderCloseInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl close_profile_recharge_order_and_return for super::RemoteProcedures { input: RuntimeProfileRechargeOrderCloseInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRechargeCenterProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/compact_external_generation_job_payloads_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/compact_external_generation_job_payloads_and_return_procedure.rs index 416c06161..e98609333 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/compact_external_generation_job_payloads_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/compact_external_generation_job_payloads_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait compact_external_generation_job_payloads_and_return { input: ExternalGenerationJobPayloadCompactionInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl compact_external_generation_job_payloads_and_return for super::RemoteProced input: ExternalGenerationJobPayloadCompactionInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp.invoke_procedure_with_callback::<_, ExternalGenerationJobPayloadCompactionProcedureResult>( "compact_external_generation_job_payloads_and_return", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs index 51375935c..e59ab8f0e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait complete_ai_stage_and_return { input: AiStageCompletionInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl complete_ai_stage_and_return for super::RemoteProcedures { input: AiStageCompletionInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AiTaskProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs index 040af6392..ca7eab9f8 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait complete_ai_task_and_return { input: AiTaskFinishInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl complete_ai_task_and_return for super::RemoteProcedures { input: AiTaskFinishInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AiTaskProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/complete_editor_asset_group_cohort_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/complete_editor_asset_group_cohort_and_return_procedure.rs index 189f54bd5..7b2a74f89 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/complete_editor_asset_group_cohort_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/complete_editor_asset_group_cohort_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait complete_editor_asset_group_cohort_and_return { input: EditorAssetGroupCohortCompleteInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl complete_editor_asset_group_cohort_and_return for super::RemoteProcedures { input: EditorAssetGroupCohortCompleteInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorAssetProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/complete_external_generation_job_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/complete_external_generation_job_and_return_procedure.rs index 9c923b968..cebd89edd 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/complete_external_generation_job_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/complete_external_generation_job_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait complete_external_generation_job_and_return { input: ExternalGenerationJobCompleteInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl complete_external_generation_job_and_return for super::RemoteProcedures { input: ExternalGenerationJobCompleteInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/complete_profile_recharge_order_expiration_schedule_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/complete_profile_recharge_order_expiration_schedule_and_return_procedure.rs index b3c8a39e2..64040eb0e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/complete_profile_recharge_order_expiration_schedule_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/complete_profile_recharge_order_expiration_schedule_and_return_procedure.rs @@ -34,13 +34,13 @@ pub trait complete_profile_recharge_order_expiration_schedule_and_return { input: RuntimeProfileRechargeOrderExpirationCompleteInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result< - RuntimeProfileRechargeOrderExpirationCompleteProcedureResult, - __sdk::InternalError, - >, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result< + RuntimeProfileRechargeOrderExpirationCompleteProcedureResult, + __sdk::InternalError, + >, + ) + Send + + 'static, ); } @@ -50,13 +50,13 @@ impl complete_profile_recharge_order_expiration_schedule_and_return for super::R input: RuntimeProfileRechargeOrderExpirationCompleteInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result< - RuntimeProfileRechargeOrderExpirationCompleteProcedureResult, - __sdk::InternalError, - >, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result< + RuntimeProfileRechargeOrderExpirationCompleteProcedureResult, + __sdk::InternalError, + >, + ) + Send + + 'static, ) { self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeOrderExpirationCompleteProcedureResult>( "complete_profile_recharge_order_expiration_schedule_and_return", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs index 0b4f26b2b..cc65f7445 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait confirm_asset_object_and_return { input: AssetObjectUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl confirm_asset_object_and_return for super::RemoteProcedures { input: AssetObjectUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AssetObjectProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_reducer.rs index 183c2efa8..f5edb63a4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_reducer.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_reducer.rs @@ -47,9 +47,11 @@ pub trait confirm_asset_object { &self, input: AssetObjectUpsertInput, - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) -> __sdk::Result<()>; } @@ -58,9 +60,11 @@ impl confirm_asset_object for super::RemoteReducers { &self, input: AssetObjectUpsertInput, - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) -> __sdk::Result<()> { self.imp .invoke_reducer_with_callback(ConfirmAssetObjectArgs { input }, callback) diff --git a/server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs index 3d3e47a20..11394b665 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait consume_profile_wallet_points_and_return { input: RuntimeProfileWalletAdjustmentInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl consume_profile_wallet_points_and_return for super::RemoteProcedures { input: RuntimeProfileWalletAdjustmentInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileWalletAdjustmentProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_admin_account_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_admin_account_and_return_procedure.rs index 0271d730c..dc9f883ec 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/create_admin_account_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_admin_account_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait create_admin_account_and_return { input: AdminAccountCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl create_admin_account_and_return for super::RemoteProcedures { input: AdminAccountCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AdminAccountProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs index 20d8ceee1..a2f40fd06 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait create_ai_task_and_return { input: AiTaskCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl create_ai_task_and_return for super::RemoteProcedures { input: AiTaskCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AiTaskProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_reducer.rs index 213f28e58..b87207f02 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_reducer.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_reducer.rs @@ -47,9 +47,11 @@ pub trait create_ai_task { &self, input: AiTaskCreateInput, - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) -> __sdk::Result<()>; } @@ -58,9 +60,11 @@ impl create_ai_task for super::RemoteReducers { &self, input: AiTaskCreateInput, - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) -> __sdk::Result<()> { self.imp .invoke_reducer_with_callback(CreateAiTaskArgs { input }, callback) diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_editor_agent_conversation_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_agent_conversation_and_return_procedure.rs index 0317b0856..6e45ee7c2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/create_editor_agent_conversation_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_agent_conversation_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait create_editor_agent_conversation_and_return { input: EditorAgentConversationCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl create_editor_agent_conversation_and_return for super::RemoteProcedures { input: EditorAgentConversationCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorAgentConversationProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_editor_asset_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_asset_and_return_procedure.rs index 8ca475434..e1926a0ef 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/create_editor_asset_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_asset_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait create_editor_asset_and_return { input: EditorAssetCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl create_editor_asset_and_return for super::RemoteProcedures { input: EditorAssetCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorAssetProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_editor_asset_folder_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_asset_folder_and_return_procedure.rs index 33ec7f85c..e45dcf833 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/create_editor_asset_folder_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_asset_folder_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait create_editor_asset_folder_and_return { input: EditorAssetFolderCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl create_editor_asset_folder_and_return for super::RemoteProcedures { input: EditorAssetFolderCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorAssetFolderProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_editor_project_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_project_and_return_procedure.rs index 7340d97d3..7c305c26e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/create_editor_project_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_project_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait create_editor_project_and_return { input: EditorProjectCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl create_editor_project_and_return for super::RemoteProcedures { input: EditorProjectCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorProjectProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_editor_project_resource_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_project_resource_and_return_procedure.rs index 649d2a3e0..957e3aff0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/create_editor_project_resource_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_project_resource_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait create_editor_project_resource_and_return { input: EditorProjectResourceCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl create_editor_project_resource_and_return for super::RemoteProcedures { input: EditorProjectResourceCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorProjectResourceProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_external_api_key_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_external_api_key_and_return_procedure.rs index daf4a676f..7dc438368 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/create_external_api_key_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_external_api_key_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait create_external_api_key_and_return { input: ExternalApiKeyCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl create_external_api_key_and_return for super::RemoteProcedures { input: ExternalApiKeyCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalApiKeyProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_profile_recharge_order_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_profile_recharge_order_and_return_procedure.rs index 893fbdf68..1c53f6aea 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/create_profile_recharge_order_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_profile_recharge_order_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait create_profile_recharge_order_and_return { input: RuntimeProfileRechargeOrderCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl create_profile_recharge_order_and_return for super::RemoteProcedures { input: RuntimeProfileRechargeOrderCreateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRechargeCenterProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_agent_conversation_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_agent_conversation_and_return_procedure.rs index 95157cf96..e7bf76bfd 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_agent_conversation_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_agent_conversation_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait delete_editor_agent_conversation_and_return { input: EditorAgentConversationDeleteInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl delete_editor_agent_conversation_and_return for super::RemoteProcedures { input: EditorAgentConversationDeleteInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorAgentConversationProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_asset_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_asset_and_return_procedure.rs index 4c71ecd53..a7c50753a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_asset_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_asset_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait delete_editor_asset_and_return { input: EditorAssetDeleteInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl delete_editor_asset_and_return for super::RemoteProcedures { input: EditorAssetDeleteInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorAssetProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_asset_folder_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_asset_folder_and_return_procedure.rs index 6500a7c79..15aae6008 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_asset_folder_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_asset_folder_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait delete_editor_asset_folder_and_return { input: EditorAssetFolderDeleteInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl delete_editor_asset_folder_and_return for super::RemoteProcedures { input: EditorAssetFolderDeleteInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorAssetFolderProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_project_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_project_and_return_procedure.rs index 529aec929..4586a8691 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_project_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_project_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait delete_editor_project_and_return { input: EditorProjectDeleteInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl delete_editor_project_and_return for super::RemoteProcedures { input: EditorProjectDeleteInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorProjectDeleteProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/enqueue_external_generation_job_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/enqueue_external_generation_job_and_return_procedure.rs index cd14e1432..e38fdf29d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/enqueue_external_generation_job_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/enqueue_external_generation_job_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait enqueue_external_generation_job_and_return { input: ExternalGenerationJobEnqueueInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl enqueue_external_generation_job_and_return for super::RemoteProcedures { input: ExternalGenerationJobEnqueueInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/enqueue_profile_wallet_refund_outbox_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/enqueue_profile_wallet_refund_outbox_and_return_procedure.rs new file mode 100644 index 000000000..4f5f28c68 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/enqueue_profile_wallet_refund_outbox_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::runtime_profile_wallet_refund_outbox_enqueue_input_type::RuntimeProfileWalletRefundOutboxEnqueueInput; +use super::runtime_profile_wallet_refund_outbox_procedure_result_type::RuntimeProfileWalletRefundOutboxProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct EnqueueProfileWalletRefundOutboxAndReturnArgs { + pub input: RuntimeProfileWalletRefundOutboxEnqueueInput, +} + +impl __sdk::InModule for EnqueueProfileWalletRefundOutboxAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `enqueue_profile_wallet_refund_outbox_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait enqueue_profile_wallet_refund_outbox_and_return { + fn enqueue_profile_wallet_refund_outbox_and_return( + &self, + input: RuntimeProfileWalletRefundOutboxEnqueueInput, + ) { + self.enqueue_profile_wallet_refund_outbox_and_return_then(input, |_, _| {}); + } + + fn enqueue_profile_wallet_refund_outbox_and_return_then( + &self, + input: RuntimeProfileWalletRefundOutboxEnqueueInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl enqueue_profile_wallet_refund_outbox_and_return for super::RemoteProcedures { + fn enqueue_profile_wallet_refund_outbox_and_return_then( + &self, + input: RuntimeProfileWalletRefundOutboxEnqueueInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileWalletRefundOutboxProcedureResult>( + "enqueue_profile_wallet_refund_outbox_and_return", + EnqueueProfileWalletRefundOutboxAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/ensure_analytics_date_dimension_for_date_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/ensure_analytics_date_dimension_for_date_reducer.rs index 30b9ba357..a6ea3098f 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/ensure_analytics_date_dimension_for_date_reducer.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/ensure_analytics_date_dimension_for_date_reducer.rs @@ -50,9 +50,11 @@ pub trait ensure_analytics_date_dimension_for_date { &self, input: AnalyticsDateDimensionEnsureInput, - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) -> __sdk::Result<()>; } @@ -61,9 +63,11 @@ impl ensure_analytics_date_dimension_for_date for super::RemoteReducers { &self, input: AnalyticsDateDimensionEnsureInput, - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) -> __sdk::Result<()> { self.imp.invoke_reducer_with_callback( EnsureAnalyticsDateDimensionForDateArgs { input }, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/expire_profile_recharge_order_timer_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/expire_profile_recharge_order_timer_reducer.rs index b9e5a5db5..4ee98c011 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/expire_profile_recharge_order_timer_reducer.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/expire_profile_recharge_order_timer_reducer.rs @@ -50,9 +50,11 @@ pub trait expire_profile_recharge_order_timer { &self, timer: ProfileRechargeOrderExpirationTimer, - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) -> __sdk::Result<()>; } @@ -61,9 +63,11 @@ impl expire_profile_recharge_order_timer for super::RemoteReducers { &self, timer: ProfileRechargeOrderExpirationTimer, - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) -> __sdk::Result<()> { self.imp .invoke_reducer_with_callback(ExpireProfileRechargeOrderTimerArgs { timer }, callback) diff --git a/server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_projection_from_tables_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_projection_from_tables_procedure.rs index ece547ce8..9334aa833 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_projection_from_tables_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_projection_from_tables_procedure.rs @@ -27,10 +27,10 @@ pub trait export_auth_store_projection_from_tables { &self, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -39,10 +39,10 @@ impl export_auth_store_projection_from_tables for super::RemoteProcedures { &self, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AuthStoreProjectionProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs index 3dfe18f83..d850737bc 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs @@ -31,10 +31,10 @@ pub trait export_database_migration_to_file { input: DatabaseMigrationExportInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl export_database_migration_to_file for super::RemoteProcedures { input: DatabaseMigrationExportInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_event_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_event_type.rs index 32c820175..4658052db 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_event_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_event_type.rs @@ -56,6 +56,7 @@ impl __sdk::__query_builder::HasCols for ExternalGenerationJobEvent { /// Provides typed access to indexed columns for query building. pub struct ExternalGenerationJobEventIxCols { pub event_id: __sdk::__query_builder::IxCol, + pub job_id: __sdk::__query_builder::IxCol, } impl __sdk::__query_builder::HasIxCols for ExternalGenerationJobEvent { @@ -63,6 +64,7 @@ impl __sdk::__query_builder::HasIxCols for ExternalGenerationJobEvent { fn ix_cols(table_name: &'static str) -> Self::IxCols { ExternalGenerationJobEventIxCols { event_id: __sdk::__query_builder::IxCol::new(table_name, "event_id"), + job_id: __sdk::__query_builder::IxCol::new(table_name, "job_id"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_retention_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_retention_input_type.rs new file mode 100644 index 000000000..cffb3527a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_retention_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 ExternalGenerationJobRetentionInput { + pub source_module: String, + pub limit: u32, + pub cursor_job_id: Option, + pub completed_before_micros: i64, + pub dry_run: bool, +} + +impl __sdk::InModule for ExternalGenerationJobRetentionInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_retention_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_retention_procedure_result_type.rs new file mode 100644 index 000000000..1f3883749 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_retention_procedure_result_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 ExternalGenerationJobRetentionProcedureResult { + pub ok: bool, + pub dry_run: bool, + pub scanned_count: u64, + pub selected_count: u32, + pub deleted_job_count: u32, + pub deleted_summary_count: u32, + pub deleted_event_count: u32, + pub next_cursor_job_id: Option, + pub has_more: bool, + pub error_message: Option, +} + +impl __sdk::InModule for ExternalGenerationJobRetentionProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs index 46090a010..3194799b9 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait fail_ai_task_and_return { input: AiTaskFailureInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl fail_ai_task_and_return for super::RemoteProcedures { input: AiTaskFailureInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AiTaskProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/fail_external_generation_job_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/fail_external_generation_job_and_return_procedure.rs index 46c1f8846..92be5608f 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/fail_external_generation_job_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/fail_external_generation_job_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait fail_external_generation_job_and_return { input: ExternalGenerationJobFailInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl fail_external_generation_job_and_return for super::RemoteProcedures { input: ExternalGenerationJobFailInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/find_editor_asset_group_source_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/find_editor_asset_group_source_and_return_procedure.rs index 7b8d7dbd0..1c8b4c32a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/find_editor_asset_group_source_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/find_editor_asset_group_source_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait find_editor_asset_group_source_and_return { input: EditorAssetGroupSourceLookupInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl find_editor_asset_group_source_and_return for super::RemoteProcedures { input: EditorAssetGroupSourceLookupInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorAssetProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_admin_account_by_id_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_admin_account_by_id_and_return_procedure.rs index 7dccc1606..f6a70ccf2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_admin_account_by_id_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_admin_account_by_id_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_admin_account_by_id_and_return { input: AdminAccountGetByIdInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_admin_account_by_id_and_return for super::RemoteProcedures { input: AdminAccountGetByIdInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AdminAccountProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_admin_account_by_username_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_admin_account_by_username_and_return_procedure.rs index a441962e4..240260454 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_admin_account_by_username_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_admin_account_by_username_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_admin_account_by_username_and_return { input: AdminAccountGetByUsernameInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_admin_account_by_username_and_return for super::RemoteProcedures { input: AdminAccountGetByUsernameInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AdminAccountCredentialProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_admin_dashboard_stats_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_admin_dashboard_stats_and_return_procedure.rs index ec663a6d4..4fc1f2929 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_admin_dashboard_stats_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_admin_dashboard_stats_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_admin_dashboard_stats_and_return { input: AdminDashboardStatsInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_admin_dashboard_stats_and_return for super::RemoteProcedures { input: AdminDashboardStatsInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AdminDashboardStatsProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_id_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_id_and_return_procedure.rs index b1d165b00..edb842e1e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_id_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_id_and_return_procedure.rs @@ -30,10 +30,10 @@ pub trait get_asset_object_by_id_and_return { asset_object_id: String, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -43,10 +43,10 @@ impl get_asset_object_by_id_and_return for super::RemoteProcedures { asset_object_id: String, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AssetObjectProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_location_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_location_and_return_procedure.rs index 9254cc5c9..c7ecc9e73 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_location_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_location_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_asset_object_by_location_and_return { input: AssetObjectLocationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_asset_object_by_location_and_return for super::RemoteProcedures { input: AssetObjectLocationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AssetObjectProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_asset_read_access_by_location_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_read_access_by_location_and_return_procedure.rs index bb8314cfa..c2e89aa8e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_asset_read_access_by_location_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_read_access_by_location_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_asset_read_access_by_location_and_return { input: AssetObjectLocationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_asset_read_access_by_location_and_return for super::RemoteProcedures { input: AssetObjectLocationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AssetObjectReadAccessProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_agent_conversation_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_agent_conversation_and_return_procedure.rs index 79b9f43f1..e45514af6 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_agent_conversation_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_agent_conversation_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_editor_agent_conversation_and_return { input: EditorAgentConversationGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_editor_agent_conversation_and_return for super::RemoteProcedures { input: EditorAgentConversationGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorAgentConversationProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_library_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_library_and_return_procedure.rs index dca803bbf..c1053c14c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_library_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_library_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_editor_asset_library_and_return { input: EditorAssetLibraryGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_editor_asset_library_and_return for super::RemoteProcedures { input: EditorAssetLibraryGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorAssetLibraryProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_generation_pricing_config_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_generation_pricing_config_and_return_procedure.rs index e64edf0c2..33520d813 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_generation_pricing_config_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_generation_pricing_config_and_return_procedure.rs @@ -27,10 +27,10 @@ pub trait get_editor_generation_pricing_config_and_return { &self, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -39,10 +39,10 @@ impl get_editor_generation_pricing_config_and_return for super::RemoteProcedures &self, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorGenerationPricingConfigProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_project_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_project_and_return_procedure.rs index 07d5c4fe1..f01b87343 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_project_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_project_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_editor_project_and_return { input: EditorProjectGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_editor_project_and_return for super::RemoteProcedures { input: EditorProjectGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorProjectProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_showcase_campaign_config_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_showcase_campaign_config_and_return_procedure.rs index baeb8c837..5d7332290 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_showcase_campaign_config_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_showcase_campaign_config_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait get_editor_showcase_campaign_config_and_return { input: EditorShowcaseCampaignConfigGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl get_editor_showcase_campaign_config_and_return for super::RemoteProcedures input: EditorShowcaseCampaignConfigGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorShowcaseCampaignConfigProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_and_return_procedure.rs index e2bc98a33..51ce17456 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_external_generation_job_and_return { input: ExternalGenerationJobGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_external_generation_job_and_return for super::RemoteProcedures { input: ExternalGenerationJobGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_result_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_result_and_return_procedure.rs index 367154dfb..d0ff920cd 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_result_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_result_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_external_generation_job_result_and_return { input: ExternalGenerationJobGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_external_generation_job_result_and_return for super::RemoteProcedures { input: ExternalGenerationJobGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalGenerationJobResultProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_summary_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_summary_and_return_procedure.rs index 2b5703e72..2009e65a2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_summary_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_summary_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_external_generation_job_summary_and_return { input: ExternalGenerationJobGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_external_generation_job_summary_and_return for super::RemoteProcedures input: ExternalGenerationJobGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalGenerationJobSummaryProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_queue_stats_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_queue_stats_and_return_procedure.rs index 9d7a98a05..429d22f4b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_queue_stats_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_queue_stats_and_return_procedure.rs @@ -27,10 +27,10 @@ pub trait get_external_generation_queue_stats_and_return { &self, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -39,10 +39,10 @@ impl get_external_generation_queue_stats_and_return for super::RemoteProcedures &self, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalGenerationQueueStatsProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_feature_gate_config_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_feature_gate_config_procedure.rs index 5e2bb5c7a..ab7b59b18 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_feature_gate_config_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_feature_gate_config_procedure.rs @@ -27,10 +27,10 @@ pub trait get_feature_gate_config { &self, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -39,10 +39,10 @@ impl get_feature_gate_config for super::RemoteProcedures { &self, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, FeatureGateConfigProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs index 38200b75a..6c48fafb3 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs @@ -31,10 +31,10 @@ pub trait get_profile_dashboard { input: RuntimeProfileDashboardGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_profile_dashboard for super::RemoteProcedures { input: RuntimeProfileDashboardGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileDashboardProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_center_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_center_procedure.rs index bf070c9c8..3e42f3d57 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_center_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_center_procedure.rs @@ -31,10 +31,10 @@ pub trait get_profile_recharge_center { input: RuntimeProfileRechargeCenterGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_profile_recharge_center for super::RemoteProcedures { input: RuntimeProfileRechargeCenterGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRechargeCenterProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_order_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_order_and_return_procedure.rs index 437f0048b..f187bc6fb 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_order_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_order_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_profile_recharge_order_and_return { input: RuntimeProfileRechargeOrderGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_profile_recharge_order_and_return for super::RemoteProcedures { input: RuntimeProfileRechargeOrderGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRechargeCenterProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_refund_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_refund_and_return_procedure.rs index d1a584f1b..977396098 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_refund_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_refund_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_profile_recharge_refund_and_return { input: RuntimeProfileRechargeRefundGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_profile_recharge_refund_and_return for super::RemoteProcedures { input: RuntimeProfileRechargeRefundGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_refund_bill_checkpoint_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_refund_bill_checkpoint_and_return_procedure.rs index 9cb29fc5b..2a9b9dcca 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_refund_bill_checkpoint_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_refund_bill_checkpoint_and_return_procedure.rs @@ -34,13 +34,10 @@ pub trait get_profile_recharge_refund_bill_checkpoint_and_return { input: RuntimeProfileRechargeRefundBillCheckpointGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result< - RuntimeProfileRechargeRefundBillCheckpointProcedureResult, - __sdk::InternalError, - >, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -50,13 +47,10 @@ impl get_profile_recharge_refund_bill_checkpoint_and_return for super::RemotePro input: RuntimeProfileRechargeRefundBillCheckpointGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result< - RuntimeProfileRechargeRefundBillCheckpointProcedureResult, - __sdk::InternalError, - >, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundBillCheckpointProcedureResult>( "get_profile_recharge_refund_bill_checkpoint_and_return", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_referral_invite_center_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_referral_invite_center_procedure.rs index 2b3dcdadc..c72214843 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_referral_invite_center_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_referral_invite_center_procedure.rs @@ -31,10 +31,10 @@ pub trait get_profile_referral_invite_center { input: RuntimeReferralInviteCenterGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_profile_referral_invite_center for super::RemoteProcedures { input: RuntimeReferralInviteCenterGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeReferralInviteCenterProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_task_center_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_task_center_procedure.rs index 105a4f98c..0aa83260a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_task_center_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_task_center_procedure.rs @@ -31,10 +31,10 @@ pub trait get_profile_task_center { input: RuntimeProfileTaskCenterGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_profile_task_center for super::RemoteProcedures { input: RuntimeProfileTaskCenterGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileTaskCenterProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_recent_editor_project_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_recent_editor_project_and_return_procedure.rs index b16395853..ea9b9720c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_recent_editor_project_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_recent_editor_project_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait get_recent_editor_project_and_return { input: EditorProjectGetRecentInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_recent_editor_project_and_return for super::RemoteProcedures { input: EditorProjectGetRecentInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorProjectProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs index 261caed19..4ca8b03ea 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs @@ -31,10 +31,10 @@ pub trait get_runtime_setting_or_default { input: RuntimeSettingGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_runtime_setting_or_default for super::RemoteProcedures { input: RuntimeSettingGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeSettingProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/grant_new_user_registration_wallet_reward_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/grant_new_user_registration_wallet_reward_procedure.rs index c1d7b6dea..71e481514 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/grant_new_user_registration_wallet_reward_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/grant_new_user_registration_wallet_reward_procedure.rs @@ -31,10 +31,10 @@ pub trait grant_new_user_registration_wallet_reward { input: RuntimeProfileDashboardGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl grant_new_user_registration_wallet_reward for super::RemoteProcedures { input: RuntimeProfileDashboardGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileWalletAdjustmentProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_chunks_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_chunks_procedure.rs index 731574800..080dda548 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_chunks_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_chunks_procedure.rs @@ -31,10 +31,10 @@ pub trait import_database_migration_from_chunks { input: DatabaseMigrationImportChunksInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl import_database_migration_from_chunks for super::RemoteProcedures { input: DatabaseMigrationImportChunksInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_file_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_file_procedure.rs index 7b2322ee5..2ce4ee2ad 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_file_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_file_procedure.rs @@ -31,10 +31,10 @@ pub trait import_database_migration_from_file { input: DatabaseMigrationImportInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl import_database_migration_from_file for super::RemoteProcedures { input: DatabaseMigrationImportInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_chunks_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_chunks_procedure.rs index 51ff565c0..bbe493578 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_chunks_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_chunks_procedure.rs @@ -34,10 +34,10 @@ pub trait import_database_migration_incremental_from_chunks { input: DatabaseMigrationImportChunksInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl import_database_migration_incremental_from_chunks for super::RemoteProcedur input: DatabaseMigrationImportChunksInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_file_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_file_procedure.rs index 2fc318044..f911c87f8 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_file_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_file_procedure.rs @@ -31,10 +31,10 @@ pub trait import_database_migration_incremental_from_file { input: DatabaseMigrationImportInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl import_database_migration_incremental_from_file for super::RemoteProcedures input: DatabaseMigrationImportInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/initialize_editor_generation_pricing_config_if_missing_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/initialize_editor_generation_pricing_config_if_missing_and_return_procedure.rs index cc4468e45..841e0298c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/initialize_editor_generation_pricing_config_if_missing_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/initialize_editor_generation_pricing_config_if_missing_and_return_procedure.rs @@ -37,10 +37,10 @@ pub trait initialize_editor_generation_pricing_config_if_missing_and_return { input: EditorGenerationPricingConfigUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -50,10 +50,10 @@ impl initialize_editor_generation_pricing_config_if_missing_and_return for super input: EditorGenerationPricingConfigUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorGenerationPricingConfigProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_admin_accounts_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_admin_accounts_and_return_procedure.rs index 0594f17b3..8258f63a2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_admin_accounts_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_admin_accounts_and_return_procedure.rs @@ -27,10 +27,10 @@ pub trait list_admin_accounts_and_return { &self, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -39,10 +39,10 @@ impl list_admin_accounts_and_return for super::RemoteProcedures { &self, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AdminAccountProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_asset_history_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_asset_history_and_return_procedure.rs index bcc2a742c..ea689b101 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_asset_history_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_asset_history_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait list_asset_history_and_return { input: AssetHistoryListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl list_asset_history_and_return for super::RemoteProcedures { input: AssetHistoryListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AssetHistoryListResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_editor_agent_conversations_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_editor_agent_conversations_and_return_procedure.rs index 148f968cf..b1f34b959 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_editor_agent_conversations_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_editor_agent_conversations_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait list_editor_agent_conversations_and_return { input: EditorAgentConversationListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl list_editor_agent_conversations_and_return for super::RemoteProcedures { input: EditorAgentConversationListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorAgentConversationListProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_editor_projects_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_editor_projects_and_return_procedure.rs index fca9595d0..c0181cff2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_editor_projects_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_editor_projects_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait list_editor_projects_and_return { input: EditorProjectListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl list_editor_projects_and_return for super::RemoteProcedures { input: EditorProjectListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorProjectListProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_external_api_keys_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_external_api_keys_and_return_procedure.rs index ca827a562..d0ab651fb 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_external_api_keys_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_external_api_keys_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait list_external_api_keys_and_return { input: ExternalApiKeyListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl list_external_api_keys_and_return for super::RemoteProcedures { input: ExternalApiKeyListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalApiKeyProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_external_generation_job_summaries_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_external_generation_job_summaries_and_return_procedure.rs index 47e617b0d..e6aa64d34 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_external_generation_job_summaries_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_external_generation_job_summaries_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait list_external_generation_job_summaries_and_return { input: ExternalGenerationJobListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl list_external_generation_job_summaries_and_return for super::RemoteProcedur input: ExternalGenerationJobListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalGenerationJobSummaryProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_external_generation_jobs_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_external_generation_jobs_and_return_procedure.rs index 05f3b53ef..5164ba9c7 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_external_generation_jobs_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_external_generation_jobs_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait list_external_generation_jobs_and_return { input: ExternalGenerationJobListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl list_external_generation_jobs_and_return for super::RemoteProcedures { input: ExternalGenerationJobListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_profile_recharge_refund_holds_for_reconciliation_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_profile_recharge_refund_holds_for_reconciliation_procedure.rs index 6bcdf41c3..c8d5ee7e8 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_profile_recharge_refund_holds_for_reconciliation_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_profile_recharge_refund_holds_for_reconciliation_procedure.rs @@ -34,10 +34,10 @@ pub trait list_profile_recharge_refund_holds_for_reconciliation { input: RuntimeProfileRechargeRefundHoldListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl list_profile_recharge_refund_holds_for_reconciliation for super::RemoteProc input: RuntimeProfileRechargeRefundHoldListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundHoldListProcedureResult>( "list_profile_recharge_refund_holds_for_reconciliation", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_profile_recharge_refunds_for_reconciliation_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_profile_recharge_refunds_for_reconciliation_procedure.rs index 0a77320bf..9d089bdc8 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_profile_recharge_refunds_for_reconciliation_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_profile_recharge_refunds_for_reconciliation_procedure.rs @@ -34,10 +34,10 @@ pub trait list_profile_recharge_refunds_for_reconciliation { input: RuntimeProfileRechargeRefundReconciliationListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl list_profile_recharge_refunds_for_reconciliation for super::RemoteProcedure input: RuntimeProfileRechargeRefundReconciliationListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundListProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs index 23496701a..d51f0df2d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs @@ -31,10 +31,10 @@ pub trait list_profile_wallet_ledger { input: RuntimeProfileWalletLedgerListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl list_profile_wallet_ledger for super::RemoteProcedures { input: RuntimeProfileWalletLedgerListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileWalletLedgerProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_project_resources_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_project_resources_and_return_procedure.rs index 70b2895cf..b594fe7ff 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_project_resources_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_project_resources_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait list_public_editor_project_resources_and_return { input: EditorProjectResourcePublicShowcaseListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl list_public_editor_project_resources_and_return for super::RemoteProcedures input: EditorProjectResourcePublicShowcaseListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorProjectResourceListProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_showcase_assets_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_showcase_assets_and_return_procedure.rs index 9e357db4f..b6b5ad8b8 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_showcase_assets_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_showcase_assets_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait list_public_editor_showcase_assets_and_return { input: EditorShowcaseAssetPublicListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl list_public_editor_showcase_assets_and_return for super::RemoteProcedures { input: EditorShowcaseAssetPublicListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorShowcaseAssetListProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_showcase_assets_for_viewer_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_showcase_assets_for_viewer_and_return_procedure.rs index f89fc1ca9..816054c2c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_showcase_assets_for_viewer_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_public_editor_showcase_assets_for_viewer_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait list_public_editor_showcase_assets_for_viewer_and_return { input: EditorShowcaseAssetViewerListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl list_public_editor_showcase_assets_for_viewer_and_return for super::RemoteP input: EditorShowcaseAssetViewerListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorShowcaseAssetViewerListProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_unchecked_expired_profile_recharge_orders_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_unchecked_expired_profile_recharge_orders_procedure.rs index 500ca0cf2..e930565c3 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/list_unchecked_expired_profile_recharge_orders_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_unchecked_expired_profile_recharge_orders_procedure.rs @@ -34,13 +34,13 @@ pub trait list_unchecked_expired_profile_recharge_orders { input: RuntimeProfileRechargeOrderExpirationCheckListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result< - RuntimeProfileRechargeOrderExpirationCheckListProcedureResult, - __sdk::InternalError, - >, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result< + RuntimeProfileRechargeOrderExpirationCheckListProcedureResult, + __sdk::InternalError, + >, + ) + Send + + 'static, ); } @@ -50,13 +50,13 @@ impl list_unchecked_expired_profile_recharge_orders for super::RemoteProcedures input: RuntimeProfileRechargeOrderExpirationCheckListInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result< - RuntimeProfileRechargeOrderExpirationCheckListProcedureResult, - __sdk::InternalError, - >, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result< + RuntimeProfileRechargeOrderExpirationCheckListProcedureResult, + __sdk::InternalError, + >, + ) + Send + + 'static, ) { self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeOrderExpirationCheckListProcedureResult>( "list_unchecked_expired_profile_recharge_orders", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mark_editor_showcase_asset_refunded_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/mark_editor_showcase_asset_refunded_and_return_procedure.rs index 2da58f2ca..a2d7ad962 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mark_editor_showcase_asset_refunded_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mark_editor_showcase_asset_refunded_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait mark_editor_showcase_asset_refunded_and_return { input: EditorShowcaseAssetRefundMarkInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl mark_editor_showcase_asset_refunded_and_return for super::RemoteProcedures input: EditorShowcaseAssetRefundMarkInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorShowcaseAssetProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mark_profile_recharge_order_expiration_checked_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/mark_profile_recharge_order_expiration_checked_procedure.rs index 5eb121685..22ec511a5 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mark_profile_recharge_order_expiration_checked_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mark_profile_recharge_order_expiration_checked_procedure.rs @@ -34,13 +34,10 @@ pub trait mark_profile_recharge_order_expiration_checked { input: RuntimeProfileRechargeOrderExpirationCheckInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result< - RuntimeProfileRechargeOrderExpirationCheckProcedureResult, - __sdk::InternalError, - >, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -50,13 +47,10 @@ impl mark_profile_recharge_order_expiration_checked for super::RemoteProcedures input: RuntimeProfileRechargeOrderExpirationCheckInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result< - RuntimeProfileRechargeOrderExpirationCheckProcedureResult, - __sdk::InternalError, - >, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeOrderExpirationCheckProcedureResult>( "mark_profile_recharge_order_expiration_checked", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mark_profile_recharge_order_paid_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/mark_profile_recharge_order_paid_and_return_procedure.rs index 09ba81c70..f412f184a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mark_profile_recharge_order_paid_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mark_profile_recharge_order_paid_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait mark_profile_recharge_order_paid_and_return { input: RuntimeProfileRechargeOrderPaidInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl mark_profile_recharge_order_paid_and_return for super::RemoteProcedures { input: RuntimeProfileRechargeOrderPaidInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRechargeCenterProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/normalize_editor_character_animation_metadata_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/normalize_editor_character_animation_metadata_and_return_procedure.rs index 68712687d..083ef15b9 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/normalize_editor_character_animation_metadata_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/normalize_editor_character_animation_metadata_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait normalize_editor_character_animation_metadata_and_return { input: EditorCharacterAnimationNormalizationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl normalize_editor_character_animation_metadata_and_return for super::RemoteP input: EditorCharacterAnimationNormalizationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp.invoke_procedure_with_callback::<_, EditorCharacterAnimationNormalizationProcedureResult>( "normalize_editor_character_animation_metadata_and_return", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_generation_result_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_generation_result_and_return_procedure.rs index 17c3468dc..6e05128b2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_generation_result_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_generation_result_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait persist_editor_generation_result_and_return { input: EditorGenerationResultPersistInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl persist_editor_generation_result_and_return for super::RemoteProcedures { input: EditorGenerationResultPersistInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorGenerationResultPersistResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs index cdccf6f35..c25ee2e1c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait persist_editor_pixel_art_result_and_return { input: EditorPixelArtResultPersistInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl persist_editor_pixel_art_result_and_return for super::RemoteProcedures { input: EditorPixelArtResultPersistInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorPixelArtResultPersistResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_spritesheet_slice_batch_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_spritesheet_slice_batch_and_return_procedure.rs index 5789c1e76..bbf93f160 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_spritesheet_slice_batch_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_spritesheet_slice_batch_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait persist_editor_spritesheet_slice_batch_and_return { input: EditorSpritesheetSliceBatchPersistInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl persist_editor_spritesheet_slice_batch_and_return for super::RemoteProcedur input: EditorSpritesheetSliceBatchPersistInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorSpritesheetSliceBatchPersistResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_generation_target_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_generation_target_and_return_procedure.rs index f734a0fdf..f2b701d1b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_generation_target_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_generation_target_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait preflight_editor_generation_target_and_return { input: EditorGenerationTargetPreflightInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl preflight_editor_generation_target_and_return for super::RemoteProcedures { input: EditorGenerationTargetPreflightInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorGenerationTargetPreflightResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs index e2aa2624a..f38144b63 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait preflight_editor_pixel_art_result_and_return { input: EditorPixelArtResultPreflightInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl preflight_editor_pixel_art_result_and_return for super::RemoteProcedures { input: EditorPixelArtResultPreflightInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorPixelArtResultPreflightResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/prepare_profile_recharge_refund_hold_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/prepare_profile_recharge_refund_hold_and_return_procedure.rs index e23c2e5c3..401e46c3b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/prepare_profile_recharge_refund_hold_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/prepare_profile_recharge_refund_hold_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait prepare_profile_recharge_refund_hold_and_return { input: RuntimeProfileRechargeRefundHoldPrepareInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl prepare_profile_recharge_refund_hold_and_return for super::RemoteProcedures input: RuntimeProfileRechargeRefundHoldPrepareInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundHoldProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/preview_profile_recharge_refund_hold_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/preview_profile_recharge_refund_hold_and_return_procedure.rs index ac8898bb6..d89f4664c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/preview_profile_recharge_refund_hold_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/preview_profile_recharge_refund_hold_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait preview_profile_recharge_refund_hold_and_return { input: RuntimeProfileRechargeRefundHoldPreviewInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl preview_profile_recharge_refund_hold_and_return for super::RemoteProcedures input: RuntimeProfileRechargeRefundHoldPreviewInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundHoldProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/process_profile_wallet_refund_outbox_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/process_profile_wallet_refund_outbox_and_return_procedure.rs new file mode 100644 index 000000000..8b6f64ccd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/process_profile_wallet_refund_outbox_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::runtime_profile_wallet_refund_outbox_procedure_result_type::RuntimeProfileWalletRefundOutboxProcedureResult; +use super::runtime_profile_wallet_refund_outbox_process_input_type::RuntimeProfileWalletRefundOutboxProcessInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ProcessProfileWalletRefundOutboxAndReturnArgs { + pub input: RuntimeProfileWalletRefundOutboxProcessInput, +} + +impl __sdk::InModule for ProcessProfileWalletRefundOutboxAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `process_profile_wallet_refund_outbox_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait process_profile_wallet_refund_outbox_and_return { + fn process_profile_wallet_refund_outbox_and_return( + &self, + input: RuntimeProfileWalletRefundOutboxProcessInput, + ) { + self.process_profile_wallet_refund_outbox_and_return_then(input, |_, _| {}); + } + + fn process_profile_wallet_refund_outbox_and_return_then( + &self, + input: RuntimeProfileWalletRefundOutboxProcessInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl process_profile_wallet_refund_outbox_and_return for super::RemoteProcedures { + fn process_profile_wallet_refund_outbox_and_return_then( + &self, + input: RuntimeProfileWalletRefundOutboxProcessInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileWalletRefundOutboxProcedureResult>( + "process_profile_wallet_refund_outbox_and_return", + ProcessProfileWalletRefundOutboxAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_refund_outbox_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_refund_outbox_table.rs new file mode 100644 index 000000000..6f6233437 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_refund_outbox_table.rs @@ -0,0 +1,235 @@ +// 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::profile_wallet_refund_outbox_type::ProfileWalletRefundOutbox; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `profile_wallet_refund_outbox`. +/// +/// Obtain a handle from the [`ProfileWalletRefundOutboxTableAccess::profile_wallet_refund_outbox`] method on [`super::RemoteTables`], +/// like `ctx.db.profile_wallet_refund_outbox()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_wallet_refund_outbox().on_insert(...)`. +pub struct ProfileWalletRefundOutboxTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `profile_wallet_refund_outbox`. +pub struct ProfileWalletRefundOutboxTableAccessor; + +impl __sdk::TableAccessor for ProfileWalletRefundOutboxTableAccessor { + type Row = ProfileWalletRefundOutbox; + type Handle<'db> = ProfileWalletRefundOutboxTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.profile_wallet_refund_outbox() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `profile_wallet_refund_outbox`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ProfileWalletRefundOutboxTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ProfileWalletRefundOutboxTableHandle`], which mediates access to the table `profile_wallet_refund_outbox`. + fn profile_wallet_refund_outbox(&self) -> ProfileWalletRefundOutboxTableHandle<'_>; +} + +impl ProfileWalletRefundOutboxTableAccess for super::RemoteTables { + fn profile_wallet_refund_outbox(&self) -> ProfileWalletRefundOutboxTableHandle<'_> { + ProfileWalletRefundOutboxTableHandle { + imp: self + .imp + .get_table::("profile_wallet_refund_outbox"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ProfileWalletRefundOutboxInsertCallbackId(__sdk::CallbackId); +pub struct ProfileWalletRefundOutboxDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for ProfileWalletRefundOutboxTableHandle<'ctx> { + type Row = ProfileWalletRefundOutbox; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for ProfileWalletRefundOutboxTableHandle<'ctx> { + type Row = ProfileWalletRefundOutbox; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ProfileWalletRefundOutboxInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileWalletRefundOutboxInsertCallbackId { + ProfileWalletRefundOutboxInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileWalletRefundOutboxInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ProfileWalletRefundOutboxDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileWalletRefundOutboxDeleteCallbackId { + ProfileWalletRefundOutboxDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileWalletRefundOutboxDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for ProfileWalletRefundOutboxTableHandle<'ctx> { + type InsertCallbackId = ProfileWalletRefundOutboxInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileWalletRefundOutboxInsertCallbackId { + ProfileWalletRefundOutboxInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileWalletRefundOutboxInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for ProfileWalletRefundOutboxTableHandle<'ctx> { + type DeleteCallbackId = ProfileWalletRefundOutboxDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileWalletRefundOutboxDeleteCallbackId { + ProfileWalletRefundOutboxDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileWalletRefundOutboxDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ProfileWalletRefundOutboxUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ProfileWalletRefundOutboxTableHandle<'ctx> { + type UpdateCallbackId = ProfileWalletRefundOutboxUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileWalletRefundOutboxUpdateCallbackId { + ProfileWalletRefundOutboxUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileWalletRefundOutboxUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for ProfileWalletRefundOutboxTableHandle<'ctx> { + type UpdateCallbackId = ProfileWalletRefundOutboxUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileWalletRefundOutboxUpdateCallbackId { + ProfileWalletRefundOutboxUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileWalletRefundOutboxUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `refund_ledger_id` unique index on the table `profile_wallet_refund_outbox`, +/// which allows point queries on the field of the same name +/// via the [`ProfileWalletRefundOutboxRefundLedgerIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_wallet_refund_outbox().refund_ledger_id().find(...)`. +pub struct ProfileWalletRefundOutboxRefundLedgerIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ProfileWalletRefundOutboxTableHandle<'ctx> { + /// Get a handle on the `refund_ledger_id` unique index on the table `profile_wallet_refund_outbox`. + pub fn refund_ledger_id(&self) -> ProfileWalletRefundOutboxRefundLedgerIdUnique<'ctx> { + ProfileWalletRefundOutboxRefundLedgerIdUnique { + imp: self.imp.get_unique_constraint::("refund_ledger_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ProfileWalletRefundOutboxRefundLedgerIdUnique<'ctx> { + /// Find the subscribed row whose `refund_ledger_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::("profile_wallet_refund_outbox"); + _table.add_unique_constraint::("refund_ledger_id", |row| &row.refund_ledger_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 `ProfileWalletRefundOutbox`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait profile_wallet_refund_outboxQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ProfileWalletRefundOutbox`. + fn profile_wallet_refund_outbox( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl profile_wallet_refund_outboxQueryTableAccess for __sdk::QueryTableAccessor { + fn profile_wallet_refund_outbox( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("profile_wallet_refund_outbox") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_refund_outbox_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_refund_outbox_type.rs new file mode 100644 index 000000000..f2c38e984 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_refund_outbox_type.rs @@ -0,0 +1,103 @@ +// 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 ProfileWalletRefundOutbox { + pub refund_ledger_id: String, + pub consume_ledger_id: String, + pub owner_user_id: String, + pub amount: u64, + pub created_at: __sdk::Timestamp, + pub asset_kind: String, + pub asset_id: String, + pub settlement_reason: String, + pub external_generation_job_id: Option, + pub external_generation_claim_attempt: Option, + pub status: String, + pub available_at: __sdk::Timestamp, + pub attempts: u32, + pub last_error: Option, + pub last_attempted_at: Option<__sdk::Timestamp>, + pub last_worker_id: Option, +} + +impl __sdk::InModule for ProfileWalletRefundOutbox { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfileWalletRefundOutbox`. +/// +/// Provides typed access to columns for query building. +pub struct ProfileWalletRefundOutboxCols { + pub refund_ledger_id: __sdk::__query_builder::Col, + pub consume_ledger_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub amount: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub asset_kind: __sdk::__query_builder::Col, + pub asset_id: __sdk::__query_builder::Col, + pub settlement_reason: __sdk::__query_builder::Col, + pub external_generation_job_id: + __sdk::__query_builder::Col>, + pub external_generation_claim_attempt: + __sdk::__query_builder::Col>, + pub status: __sdk::__query_builder::Col, + pub available_at: __sdk::__query_builder::Col, + pub attempts: __sdk::__query_builder::Col, + pub last_error: __sdk::__query_builder::Col>, + pub last_attempted_at: + __sdk::__query_builder::Col>, + pub last_worker_id: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for ProfileWalletRefundOutbox { + type Cols = ProfileWalletRefundOutboxCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfileWalletRefundOutboxCols { + refund_ledger_id: __sdk::__query_builder::Col::new(table_name, "refund_ledger_id"), + consume_ledger_id: __sdk::__query_builder::Col::new(table_name, "consume_ledger_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + amount: __sdk::__query_builder::Col::new(table_name, "amount"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + asset_kind: __sdk::__query_builder::Col::new(table_name, "asset_kind"), + asset_id: __sdk::__query_builder::Col::new(table_name, "asset_id"), + settlement_reason: __sdk::__query_builder::Col::new(table_name, "settlement_reason"), + external_generation_job_id: __sdk::__query_builder::Col::new( + table_name, + "external_generation_job_id", + ), + external_generation_claim_attempt: __sdk::__query_builder::Col::new( + table_name, + "external_generation_claim_attempt", + ), + status: __sdk::__query_builder::Col::new(table_name, "status"), + available_at: __sdk::__query_builder::Col::new(table_name, "available_at"), + attempts: __sdk::__query_builder::Col::new(table_name, "attempts"), + last_error: __sdk::__query_builder::Col::new(table_name, "last_error"), + last_attempted_at: __sdk::__query_builder::Col::new(table_name, "last_attempted_at"), + last_worker_id: __sdk::__query_builder::Col::new(table_name, "last_worker_id"), + } + } +} + +/// Indexed column accessor struct for the table `ProfileWalletRefundOutbox`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfileWalletRefundOutboxIxCols { + pub refund_ledger_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfileWalletRefundOutbox { + type IxCols = ProfileWalletRefundOutboxIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfileWalletRefundOutboxIxCols { + refund_ledger_id: __sdk::__query_builder::IxCol::new(table_name, "refund_ledger_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfileWalletRefundOutbox {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/prune_external_generation_job_history_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/prune_external_generation_job_history_and_return_procedure.rs new file mode 100644 index 000000000..877d06380 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/prune_external_generation_job_history_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::external_generation_job_retention_input_type::ExternalGenerationJobRetentionInput; +use super::external_generation_job_retention_procedure_result_type::ExternalGenerationJobRetentionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct PruneExternalGenerationJobHistoryAndReturnArgs { + pub input: ExternalGenerationJobRetentionInput, +} + +impl __sdk::InModule for PruneExternalGenerationJobHistoryAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `prune_external_generation_job_history_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait prune_external_generation_job_history_and_return { + fn prune_external_generation_job_history_and_return( + &self, + input: ExternalGenerationJobRetentionInput, + ) { + self.prune_external_generation_job_history_and_return_then(input, |_, _| {}); + } + + fn prune_external_generation_job_history_and_return_then( + &self, + input: ExternalGenerationJobRetentionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl prune_external_generation_job_history_and_return for super::RemoteProcedures { + fn prune_external_generation_job_history_and_return_then( + &self, + input: ExternalGenerationJobRetentionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalGenerationJobRetentionProcedureResult>( + "prune_external_generation_job_history_and_return", + PruneExternalGenerationJobHistoryAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/public_work_play_daily_stat_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/public_work_play_daily_stat_table.rs index 3117ed994..5b589fc7a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/public_work_play_daily_stat_table.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/public_work_play_daily_stat_table.rs @@ -222,7 +222,7 @@ pub trait public_work_play_daily_statQueryTableAccess { #[allow(non_snake_case)] /// Get a query builder for the table `PublicWorkPlayDailyStat`. fn public_work_play_daily_stat(&self) - -> __sdk::__query_builder::Table; + -> __sdk::__query_builder::Table; } impl public_work_play_daily_statQueryTableAccess for __sdk::QueryTableAccessor { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/put_database_migration_import_chunk_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/put_database_migration_import_chunk_procedure.rs index 597b25119..f3776bfdc 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/put_database_migration_import_chunk_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/put_database_migration_import_chunk_procedure.rs @@ -31,10 +31,10 @@ pub trait put_database_migration_import_chunk { input: DatabaseMigrationImportChunkInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl put_database_migration_import_chunk for super::RemoteProcedures { input: DatabaseMigrationImportChunkInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_clear_work_profile_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_clear_work_profile_table.rs index ea4538fd3..71c5a5084 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_clear_work_profile_table.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_clear_work_profile_table.rs @@ -222,7 +222,7 @@ pub trait puzzle_clear_work_profileQueryTableAccess { #[allow(non_snake_case)] /// Get a query builder for the table `PuzzleClearWorkProfileRow`. fn puzzle_clear_work_profile(&self) - -> __sdk::__query_builder::Table; + -> __sdk::__query_builder::Table; } impl puzzle_clear_work_profileQueryTableAccess for __sdk::QueryTableAccessor { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/query_analytics_metric_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/query_analytics_metric_procedure.rs index adc25bfaf..7973f5469 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/query_analytics_metric_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/query_analytics_metric_procedure.rs @@ -31,10 +31,10 @@ pub trait query_analytics_metric { input: AnalyticsMetricQueryInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl query_analytics_metric for super::RemoteProcedures { input: AnalyticsMetricQueryInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AnalyticsMetricQueryProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_daily_login_tracking_event_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_daily_login_tracking_event_and_return_procedure.rs index 9365d3359..c131f3bab 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/record_daily_login_tracking_event_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_daily_login_tracking_event_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait record_daily_login_tracking_event_and_return { input: RuntimeProfileTaskCenterGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl record_daily_login_tracking_event_and_return for super::RemoteProcedures { input: RuntimeProfileTaskCenterGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeTrackingEventProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_profile_recharge_refund_observation_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_profile_recharge_refund_observation_and_return_procedure.rs index 2ee1c67a6..5c450a579 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/record_profile_recharge_refund_observation_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_profile_recharge_refund_observation_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait record_profile_recharge_refund_observation_and_return { input: RuntimeProfileRechargeRefundObservationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl record_profile_recharge_refund_observation_and_return for super::RemoteProc input: RuntimeProfileRechargeRefundObservationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_tracking_event_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_tracking_event_and_return_procedure.rs index 01361ec79..c09132c01 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/record_tracking_event_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_tracking_event_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait record_tracking_event_and_return { input: RuntimeTrackingEventInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl record_tracking_event_and_return for super::RemoteProcedures { input: RuntimeTrackingEventInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeTrackingEventProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_tracking_events_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_tracking_events_and_return_procedure.rs index ba28d1a80..428e378f6 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/record_tracking_events_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_tracking_events_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait record_tracking_events_and_return { inputs: Vec, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl record_tracking_events_and_return for super::RemoteProcedures { inputs: Vec, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeTrackingEventBatchProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_referral_invite_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_referral_invite_code_procedure.rs index 44354acdf..efebd26a0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_referral_invite_code_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_referral_invite_code_procedure.rs @@ -31,10 +31,10 @@ pub trait redeem_profile_referral_invite_code { input: RuntimeReferralRedeemInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl redeem_profile_referral_invite_code for super::RemoteProcedures { input: RuntimeReferralRedeemInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeReferralRedeemProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs index 4d048a49b..38fc64f51 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs @@ -31,10 +31,10 @@ pub trait redeem_profile_reward_code { input: RuntimeProfileRewardCodeRedeemInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl redeem_profile_reward_code for super::RemoteProcedures { input: RuntimeProfileRewardCodeRedeemInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRewardCodeRedeemProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs index fb86172c0..a4bbd3787 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait refund_profile_wallet_points_and_return { input: RuntimeProfileWalletAdjustmentInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl refund_profile_wallet_points_and_return for super::RemoteProcedures { input: RuntimeProfileWalletAdjustmentInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileWalletAdjustmentProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/release_profile_recharge_refund_hold_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/release_profile_recharge_refund_hold_and_return_procedure.rs index 8697385ea..74e5ecadb 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/release_profile_recharge_refund_hold_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/release_profile_recharge_refund_hold_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait release_profile_recharge_refund_hold_and_return { input: RuntimeProfileRechargeRefundHoldReleaseInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl release_profile_recharge_refund_hold_and_return for super::RemoteProcedures input: RuntimeProfileRechargeRefundHoldReleaseInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundHoldProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/rename_editor_project_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/rename_editor_project_and_return_procedure.rs index 408122981..923337242 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/rename_editor_project_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/rename_editor_project_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait rename_editor_project_and_return { input: EditorProjectRenameInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl rename_editor_project_and_return for super::RemoteProcedures { input: EditorProjectRenameInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorProjectProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/renew_external_generation_job_lease_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/renew_external_generation_job_lease_and_return_procedure.rs index 4cbd45fc1..0b31241b0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/renew_external_generation_job_lease_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/renew_external_generation_job_lease_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait renew_external_generation_job_lease_and_return { input: ExternalGenerationJobRenewLeaseInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl renew_external_generation_job_lease_and_return for super::RemoteProcedures input: ExternalGenerationJobRenewLeaseInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_asset_media_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_asset_media_and_return_procedure.rs index 1b035028b..63499dde6 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_asset_media_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_asset_media_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait repair_editor_asset_media_and_return { input: EditorAssetMediaRepairInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl repair_editor_asset_media_and_return for super::RemoteProcedures { input: EditorAssetMediaRepairInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorAssetProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_canvas_resources_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_canvas_resources_and_return_procedure.rs index a45b084b1..1960b07fe 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_canvas_resources_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_canvas_resources_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait repair_editor_canvas_resources_and_return { input: EditorCanvasResourceRepairInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl repair_editor_canvas_resources_and_return for super::RemoteProcedures { input: EditorCanvasResourceRepairInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorCanvasResourceRepairProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_project_resource_media_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_project_resource_media_and_return_procedure.rs index 7e521cd37..47b5c9178 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_project_resource_media_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_project_resource_media_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait repair_editor_project_resource_media_and_return { input: EditorProjectResourceMediaRepairInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl repair_editor_project_resource_media_and_return for super::RemoteProcedures input: EditorProjectResourceMediaRepairInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorProjectResourceProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_editor_reference_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_editor_reference_and_return_procedure.rs index e57f5cfb9..f25481dbf 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/resolve_editor_reference_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_editor_reference_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait resolve_editor_reference_and_return { input: EditorReferenceResolveInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl resolve_editor_reference_and_return for super::RemoteProcedures { input: EditorReferenceResolveInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorReferenceProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/resolve_profile_recharge_refund_manual_review_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/resolve_profile_recharge_refund_manual_review_and_return_procedure.rs index 96f8eb1fe..ceb9383a9 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/resolve_profile_recharge_refund_manual_review_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/resolve_profile_recharge_refund_manual_review_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait resolve_profile_recharge_refund_manual_review_and_return { input: RuntimeProfileRechargeRefundManualReviewResolveInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl resolve_profile_recharge_refund_manual_review_and_return for super::RemoteP input: RuntimeProfileRechargeRefundManualReviewResolveInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/revoke_database_migration_operator_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/revoke_database_migration_operator_procedure.rs index feb5086ed..fe0329266 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/revoke_database_migration_operator_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/revoke_database_migration_operator_procedure.rs @@ -31,10 +31,10 @@ pub trait revoke_database_migration_operator { input: DatabaseMigrationRevokeOperatorInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl revoke_database_migration_operator for super::RemoteProcedures { input: DatabaseMigrationRevokeOperatorInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, DatabaseMigrationOperatorProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/revoke_external_api_key_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/revoke_external_api_key_and_return_procedure.rs index d893b2180..9faef438c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/revoke_external_api_key_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/revoke_external_api_key_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait revoke_external_api_key_and_return { input: ExternalApiKeyRevokeInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl revoke_external_api_key_and_return for super::RemoteProcedures { input: ExternalApiKeyRevokeInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalApiKeyProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/rollback_editor_canvas_layout_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/rollback_editor_canvas_layout_and_return_procedure.rs index ef6918cf8..0c43310f5 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/rollback_editor_canvas_layout_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/rollback_editor_canvas_layout_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait rollback_editor_canvas_layout_and_return { input: EditorCanvasLayoutMigrationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl rollback_editor_canvas_layout_and_return for super::RemoteProcedures { input: EditorCanvasLayoutMigrationInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorCanvasLayoutMigrationProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/rotate_editor_generation_runtime_service_identity_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/rotate_editor_generation_runtime_service_identity_and_return_procedure.rs index 5c4593116..25abb2448 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/rotate_editor_generation_runtime_service_identity_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/rotate_editor_generation_runtime_service_identity_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait rotate_editor_generation_runtime_service_identity_and_return { input: EditorGenerationRuntimeIdentityRotateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl rotate_editor_generation_runtime_service_identity_and_return for super::Rem input: EditorGenerationRuntimeIdentityRotateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorGenerationPricingConfigProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_enqueue_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_enqueue_input_type.rs new file mode 100644 index 000000000..14e090000 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_enqueue_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 RuntimeProfileWalletRefundOutboxEnqueueInput { + pub owner_user_id: String, + pub amount: u64, + pub refund_ledger_id: String, + pub created_at_micros: i64, + pub asset_kind: String, + pub asset_id: String, + pub settlement_reason: String, + pub external_generation_job_id: Option, + pub external_generation_claim_attempt: Option, +} + +impl __sdk::InModule for RuntimeProfileWalletRefundOutboxEnqueueInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_procedure_result_type.rs new file mode 100644 index 000000000..dae26bc0d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_procedure_result_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 RuntimeProfileWalletRefundOutboxProcedureResult { + pub ok: bool, + pub enqueued_count: u32, + pub processed_count: u32, + pub retry_count: u32, + pub failed_count: u32, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileWalletRefundOutboxProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_process_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_process_input_type.rs new file mode 100644 index 000000000..1c3f25e7c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_process_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 RuntimeProfileWalletRefundOutboxProcessInput { + pub worker_id: String, + pub limit: u32, +} + +impl __sdk::InModule for RuntimeProfileWalletRefundOutboxProcessInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_ack_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_ack_procedure.rs index a564b72be..d2e864785 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_ack_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_ack_procedure.rs @@ -31,10 +31,10 @@ pub trait save_editor_project_layout_ack { input: EditorProjectLayoutSaveInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl save_editor_project_layout_ack for super::RemoteProcedures { input: EditorProjectLayoutSaveInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorProjectLayoutSaveProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_and_return_procedure.rs index 17585bdf2..e4eb529dc 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait save_editor_project_layout_and_return { input: EditorProjectLayoutSaveInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl save_editor_project_layout_and_return for super::RemoteProcedures { input: EditorProjectLayoutSaveInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorProjectProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_v_2_ack_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_v_2_ack_procedure.rs index 457b4a069..144a5fc12 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_v_2_ack_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_v_2_ack_procedure.rs @@ -31,10 +31,10 @@ pub trait save_editor_project_layout_v_2_ack { input: EditorProjectLayoutSaveV2Input, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl save_editor_project_layout_v_2_ack for super::RemoteProcedures { input: EditorProjectLayoutSaveV2Input, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorProjectLayoutSaveV2ProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_v_2_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_v_2_and_return_procedure.rs index 4d5eabf86..f53a28da4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_v_2_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_v_2_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait save_editor_project_layout_v_2_and_return { input: EditorProjectLayoutSaveV2Input, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl save_editor_project_layout_v_2_and_return for super::RemoteProcedures { input: EditorProjectLayoutSaveV2Input, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorProjectProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/seed_analytics_date_dimensions_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/seed_analytics_date_dimensions_reducer.rs index 6e2ac3ad9..29d3b91d9 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/seed_analytics_date_dimensions_reducer.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/seed_analytics_date_dimensions_reducer.rs @@ -50,9 +50,11 @@ pub trait seed_analytics_date_dimensions { &self, input: AnalyticsDateDimensionSeedInput, - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) -> __sdk::Result<()>; } @@ -61,9 +63,11 @@ impl seed_analytics_date_dimensions for super::RemoteReducers { &self, input: AnalyticsDateDimensionSeedInput, - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) -> __sdk::Result<()> { self.imp .invoke_reducer_with_callback(SeedAnalyticsDateDimensionsArgs { input }, callback) diff --git a/server-rs/crates/spacetime-client/src/module_bindings/set_editor_showcase_asset_like_for_viewer_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/set_editor_showcase_asset_like_for_viewer_and_return_procedure.rs index ffe419e01..cca99f26e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/set_editor_showcase_asset_like_for_viewer_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/set_editor_showcase_asset_like_for_viewer_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait set_editor_showcase_asset_like_for_viewer_and_return { input: EditorShowcaseAssetLikeToggleInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl set_editor_showcase_asset_like_for_viewer_and_return for super::RemoteProce input: EditorShowcaseAssetLikeToggleInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorShowcaseAssetViewerProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_message_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_message_table.rs index 013941592..41991f0c1 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_message_table.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_message_table.rs @@ -222,7 +222,7 @@ pub trait square_hole_agent_messageQueryTableAccess { #[allow(non_snake_case)] /// Get a query builder for the table `SquareHoleAgentMessageRow`. fn square_hole_agent_message(&self) - -> __sdk::__query_builder::Table; + -> __sdk::__query_builder::Table; } impl square_hole_agent_messageQueryTableAccess for __sdk::QueryTableAccessor { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_session_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_session_table.rs index 5e4438785..ca8b8737f 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_session_table.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_session_table.rs @@ -222,7 +222,7 @@ pub trait square_hole_agent_sessionQueryTableAccess { #[allow(non_snake_case)] /// Get a query builder for the table `SquareHoleAgentSessionRow`. fn square_hole_agent_session(&self) - -> __sdk::__query_builder::Table; + -> __sdk::__query_builder::Table; } impl square_hole_agent_sessionQueryTableAccess for __sdk::QueryTableAccessor { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/start_ai_task_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/start_ai_task_reducer.rs index 5809736bd..c5cc52567 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/start_ai_task_reducer.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/start_ai_task_reducer.rs @@ -47,9 +47,11 @@ pub trait start_ai_task { &self, input: AiTaskStartInput, - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) -> __sdk::Result<()>; } @@ -58,9 +60,11 @@ impl start_ai_task for super::RemoteReducers { &self, input: AiTaskStartInput, - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) -> __sdk::Result<()> { self.imp .invoke_reducer_with_callback(StartAiTaskArgs { input }, callback) diff --git a/server-rs/crates/spacetime-client/src/module_bindings/start_ai_task_stage_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/start_ai_task_stage_reducer.rs index 1d7b7582a..24ed5b3fa 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/start_ai_task_stage_reducer.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/start_ai_task_stage_reducer.rs @@ -47,9 +47,11 @@ pub trait start_ai_task_stage { &self, input: AiTaskStageStartInput, - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) -> __sdk::Result<()>; } @@ -58,9 +60,11 @@ impl start_ai_task_stage for super::RemoteReducers { &self, input: AiTaskStageStartInput, - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, ) -> __sdk::Result<()> { self.imp .invoke_reducer_with_callback(StartAiTaskStageArgs { input }, callback) diff --git a/server-rs/crates/spacetime-client/src/module_bindings/submit_editor_showcase_asset_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/submit_editor_showcase_asset_and_return_procedure.rs index 7002e581b..367fd0fdb 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/submit_editor_showcase_asset_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/submit_editor_showcase_asset_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait submit_editor_showcase_asset_and_return { input: EditorShowcaseAssetSubmitInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl submit_editor_showcase_asset_and_return for super::RemoteProcedures { input: EditorShowcaseAssetSubmitInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorShowcaseAssetProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/submit_profile_feedback_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/submit_profile_feedback_and_return_procedure.rs index 3534df7d3..208a35a95 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/submit_profile_feedback_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/submit_profile_feedback_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait submit_profile_feedback_and_return { input: RuntimeProfileFeedbackSubmissionInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl submit_profile_feedback_and_return for super::RemoteProcedures { input: RuntimeProfileFeedbackSubmissionInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeProfileFeedbackSubmissionProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/sync_auth_store_projection_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/sync_auth_store_projection_procedure.rs index 7d4803e27..d9f1ca3e3 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/sync_auth_store_projection_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/sync_auth_store_projection_procedure.rs @@ -31,10 +31,10 @@ pub trait sync_auth_store_projection { input: AuthStoreProjectionView, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl sync_auth_store_projection for super::RemoteProcedures { input: AuthStoreProjectionView, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AuthStoreProjectionSyncProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/touch_editor_agent_conversation_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/touch_editor_agent_conversation_and_return_procedure.rs index 89741e92c..e331ab348 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/touch_editor_agent_conversation_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/touch_editor_agent_conversation_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait touch_editor_agent_conversation_and_return { input: EditorAgentConversationTouchInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl touch_editor_agent_conversation_and_return for super::RemoteProcedures { input: EditorAgentConversationTouchInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorAgentConversationProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_admin_account_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_admin_account_and_return_procedure.rs index 1eb839663..e1d2deba0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/update_admin_account_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_admin_account_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait update_admin_account_and_return { input: AdminAccountUpdateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl update_admin_account_and_return for super::RemoteProcedures { input: AdminAccountUpdateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, AdminAccountProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_editor_asset_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_editor_asset_and_return_procedure.rs index 1ec662919..4e0cfd34e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/update_editor_asset_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_editor_asset_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait update_editor_asset_and_return { input: EditorAssetUpdateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl update_editor_asset_and_return for super::RemoteProcedures { input: EditorAssetUpdateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorAssetProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_editor_asset_folder_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_editor_asset_folder_and_return_procedure.rs index a5ec71437..91ca31fc5 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/update_editor_asset_folder_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_editor_asset_folder_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait update_editor_asset_folder_and_return { input: EditorAssetFolderUpdateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl update_editor_asset_folder_and_return for super::RemoteProcedures { input: EditorAssetFolderUpdateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorAssetFolderProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_editor_project_resource_showcase_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_editor_project_resource_showcase_and_return_procedure.rs index 200432a78..c49ab8d9d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/update_editor_project_resource_showcase_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_editor_project_resource_showcase_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait update_editor_project_resource_showcase_and_return { input: EditorProjectResourceShowcaseUpdateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl update_editor_project_resource_showcase_and_return for super::RemoteProcedu input: EditorProjectResourceShowcaseUpdateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorProjectResourceProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_editor_showcase_asset_display_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_editor_showcase_asset_display_and_return_procedure.rs index 40b912558..85db684da 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/update_editor_showcase_asset_display_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_editor_showcase_asset_display_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait update_editor_showcase_asset_display_and_return { input: EditorShowcaseAssetDisplayUpdateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl update_editor_showcase_asset_display_and_return for super::RemoteProcedures input: EditorShowcaseAssetDisplayUpdateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorShowcaseAssetProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs index 890d5b4d6..a6cc1b841 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait update_external_generation_job_phase_and_return { input: ExternalGenerationJobPhaseUpdateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl update_external_generation_job_phase_and_return for super::RemoteProcedures input: ExternalGenerationJobPhaseUpdateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalGenerationJobPhaseUpdateProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_editor_generation_pricing_config_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_editor_generation_pricing_config_and_return_procedure.rs index e822ed9df..9cb7c1e77 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/upsert_editor_generation_pricing_config_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_editor_generation_pricing_config_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait upsert_editor_generation_pricing_config_and_return { input: EditorGenerationPricingConfigUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl upsert_editor_generation_pricing_config_and_return for super::RemoteProcedu input: EditorGenerationPricingConfigUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorGenerationPricingConfigProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_editor_showcase_campaign_config_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_editor_showcase_campaign_config_and_return_procedure.rs index 490d65d5d..ae0bb7a6c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/upsert_editor_showcase_campaign_config_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_editor_showcase_campaign_config_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait upsert_editor_showcase_campaign_config_and_return { input: EditorShowcaseCampaignConfigUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl upsert_editor_showcase_campaign_config_and_return for super::RemoteProcedur input: EditorShowcaseCampaignConfigUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorShowcaseCampaignConfigProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_feature_gate_config_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_feature_gate_config_procedure.rs index 3e7c3cfcb..ece1afe07 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/upsert_feature_gate_config_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_feature_gate_config_procedure.rs @@ -31,10 +31,10 @@ pub trait upsert_feature_gate_config { input: FeatureGateConfigAdminUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl upsert_feature_gate_config for super::RemoteProcedures { input: FeatureGateConfigAdminUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, FeatureGateConfigProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs index 119eab703..f8fa0351a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait upsert_runtime_setting_and_return { input: RuntimeSettingUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl upsert_runtime_setting_and_return for super::RemoteProcedures { input: RuntimeSettingUpsertInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeSettingProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/validate_auth_session_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/validate_auth_session_procedure.rs new file mode 100644 index 000000000..e96904972 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/validate_auth_session_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::auth_session_validation_input_type::AuthSessionValidationInput; +use super::auth_session_validation_procedure_result_type::AuthSessionValidationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ValidateAuthSessionArgs { + pub input: AuthSessionValidationInput, +} + +impl __sdk::InModule for ValidateAuthSessionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `validate_auth_session`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait validate_auth_session { + fn validate_auth_session(&self, input: AuthSessionValidationInput) { + self.validate_auth_session_then(input, |_, _| {}); + } + + fn validate_auth_session_then( + &self, + input: AuthSessionValidationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl validate_auth_session for super::RemoteProcedures { + fn validate_auth_session_then( + &self, + input: AuthSessionValidationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AuthSessionValidationProcedureResult>( + "validate_auth_session", + ValidateAuthSessionArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_profile_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_profile_table.rs index 87ff32057..b41c7f144 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_profile_table.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_profile_table.rs @@ -222,7 +222,7 @@ pub trait visual_novel_work_profileQueryTableAccess { #[allow(non_snake_case)] /// Get a query builder for the table `VisualNovelWorkProfileRow`. fn visual_novel_work_profile(&self) - -> __sdk::__query_builder::Table; + -> __sdk::__query_builder::Table; } impl visual_novel_work_profileQueryTableAccess for __sdk::QueryTableAccessor { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_table.rs index b02cf523d..53b91c879 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_table.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_table.rs @@ -222,7 +222,7 @@ pub trait wooden_fish_agent_sessionQueryTableAccess { #[allow(non_snake_case)] /// Get a query builder for the table `WoodenFishAgentSessionRow`. fn wooden_fish_agent_session(&self) - -> __sdk::__query_builder::Table; + -> __sdk::__query_builder::Table; } impl wooden_fish_agent_sessionQueryTableAccess for __sdk::QueryTableAccessor { diff --git a/server-rs/crates/spacetime-module/Cargo.toml b/server-rs/crates/spacetime-module/Cargo.toml index 9f264dd64..d2a516e1b 100644 --- a/server-rs/crates/spacetime-module/Cargo.toml +++ b/server-rs/crates/spacetime-module/Cargo.toml @@ -20,3 +20,4 @@ module-runtime = { workspace = true, features = ["spacetime-types"] } sha2 = { workspace = true } spacetimedb = { workspace = true, features = ["unstable"] } spacetimedb-lib = { workspace = true, features = ["serde"] } +time = { workspace = true, features = ["parsing"] } diff --git a/server-rs/crates/spacetime-module/src/ai/snapshots.rs b/server-rs/crates/spacetime-module/src/ai/snapshots.rs index 8ee4c0bec..f76d209e7 100644 --- a/server-rs/crates/spacetime-module/src/ai/snapshots.rs +++ b/server-rs/crates/spacetime-module/src/ai/snapshots.rs @@ -139,17 +139,6 @@ pub(crate) fn build_ai_text_chunk_row_id(snapshot: &AiTextChunkSnapshot) -> Stri ) } -pub(crate) fn build_ai_text_chunk_snapshot_from_row(row: &AiTextChunk) -> AiTextChunkSnapshot { - AiTextChunkSnapshot { - chunk_id: row.chunk_id.clone(), - task_id: row.task_id.clone(), - stage_kind: row.stage_kind, - sequence: row.sequence, - delta_text: row.delta_text.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - } -} - pub(crate) fn build_ai_result_reference_row( snapshot: &AiResultReferenceSnapshot, ) -> AiResultReference { diff --git a/server-rs/crates/spacetime-module/src/ai/stages.rs b/server-rs/crates/spacetime-module/src/ai/stages.rs index 8ffea6f22..86bc26bc9 100644 --- a/server-rs/crates/spacetime-module/src/ai/stages.rs +++ b/server-rs/crates/spacetime-module/src/ai/stages.rs @@ -1,9 +1,12 @@ use crate::*; use module_ai::{ - generate_ai_result_ref_id, generate_ai_text_chunk_id, normalize_optional_text, - normalize_string_list, + MAX_AI_TASK_TEXT_CHUNKS_PER_STAGE, MAX_AI_TASK_TEXT_OUTPUT_BYTES, generate_ai_result_ref_id, + generate_ai_text_chunk_id, normalize_optional_text, normalize_string_list, + validate_ai_task_snapshot_memory_limits, }; +const AI_TEXT_CHUNK_DELETE_BATCH_SIZE: usize = 256; + #[spacetimedb::table( accessor = ai_task_stage, index(accessor = by_ai_task_stage_task_id, btree(columns = [task_id])), @@ -178,6 +181,9 @@ pub(crate) fn append_ai_text_chunk_tx( if input.sequence == 0 { return Err("ai_text_chunk.sequence 必须大于 0".to_string()); } + if input.delta_text.len() > MAX_AI_TASK_TEXT_OUTPUT_BYTES { + return Err("AI 任务文本输出超过内存上限".to_string()); + } let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?; ensure_ai_task_can_transition(snapshot.status)?; @@ -200,7 +206,7 @@ pub(crate) fn append_ai_text_chunk_tx( .ai_text_chunk() .insert(build_ai_text_chunk_row(&chunk)); - let aggregated_text = collect_ai_stage_text_output(ctx, &chunk.task_id, chunk.stage_kind); + let aggregated_text = collect_ai_stage_text_output(ctx, &chunk.task_id, chunk.stage_kind)?; snapshot.status = AiTaskStatus::Running; if snapshot.started_at_micros.is_none() { @@ -215,6 +221,7 @@ pub(crate) fn append_ai_text_chunk_tx( snapshot.updated_at_micros = input.created_at_micros; snapshot.version += 1; + validate_ai_task_snapshot_memory_limits(&snapshot).map_err(str::to_string)?; persist_ai_task_snapshot(ctx, &snapshot)?; emit_ai_task_event( ctx, @@ -252,6 +259,7 @@ pub(crate) fn complete_ai_stage_tx( snapshot.updated_at_micros = input.completed_at_micros; snapshot.version += 1; + validate_ai_task_snapshot_memory_limits(&snapshot).map_err(str::to_string)?; persist_ai_task_snapshot(ctx, &snapshot)?; emit_ai_task_event( ctx, @@ -285,26 +293,27 @@ pub(crate) fn attach_ai_result_reference_tx( label: normalize_optional_text(input.label), created_at_micros: input.created_at_micros, }; - ctx.db - .ai_result_reference() - .insert(build_ai_result_reference_row(&reference)); - snapshot.result_references.push(reference); snapshot.updated_at_micros = input.created_at_micros; snapshot.version += 1; - persist_ai_task_snapshot(ctx, &snapshot)?; + validate_ai_task_snapshot_memory_limits(&snapshot).map_err(str::to_string)?; let reference = snapshot .result_references .last() + .cloned() .ok_or_else(|| "ai_result_reference 写入后缺少快照".to_string())?; + ctx.db + .ai_result_reference() + .insert(build_ai_result_reference_row(&reference)); + persist_ai_task_snapshot(ctx, &snapshot)?; emit_ai_task_event( ctx, &snapshot, AiTaskEventKind::ResultReferenceAttached, None, None, - Some(build_ai_result_reference_row_id(reference)), + Some(build_ai_result_reference_row_id(&reference)), input.created_at_micros, ); Ok(snapshot) @@ -333,29 +342,63 @@ pub(crate) fn replace_ai_task_stages( } } +pub(crate) fn delete_ai_text_chunks_for_task(ctx: &ReducerContext, task_id: &str) { + loop { + let chunk_row_ids = ctx + .db + .ai_text_chunk() + .by_ai_text_chunk_task_id() + .filter(task_id) + .take(AI_TEXT_CHUNK_DELETE_BATCH_SIZE) + .map(|row| row.text_chunk_row_id.clone()) + .collect::>(); + if chunk_row_ids.is_empty() { + break; + } + let batch_len = chunk_row_ids.len(); + for row_id in chunk_row_ids { + ctx.db.ai_text_chunk().text_chunk_row_id().delete(&row_id); + } + if batch_len < AI_TEXT_CHUNK_DELETE_BATCH_SIZE { + break; + } + } +} + pub(crate) fn collect_ai_stage_text_output( ctx: &ReducerContext, task_id: &str, stage_kind: AiTaskStageKind, -) -> Option { - let mut chunks = ctx +) -> Result, String> { + let mut chunks = Vec::new(); + let mut chunk_count = 0_usize; + let mut aggregated_bytes = 0_usize; + for row in ctx .db .ai_text_chunk() .by_ai_text_chunk_task_id() .filter(task_id) .filter(|row| row.task_id == task_id && row.stage_kind == stage_kind) - .map(|row| build_ai_text_chunk_snapshot_from_row(&row)) - .collect::>(); - chunks.sort_by_key(|chunk| chunk.sequence); + { + chunk_count = chunk_count.saturating_add(1); + if chunk_count > MAX_AI_TASK_TEXT_CHUNKS_PER_STAGE { + return Err("AI 任务文本 chunk 数量超过内存上限".to_string()); + } + aggregated_bytes = aggregated_bytes.saturating_add(row.delta_text.len()); + if aggregated_bytes > MAX_AI_TASK_TEXT_OUTPUT_BYTES { + return Err("AI 任务文本输出超过内存上限".to_string()); + } + chunks.push((row.sequence, row.delta_text.clone())); + } + chunks.sort_by_key(|(sequence, _)| *sequence); - let aggregated = chunks - .into_iter() - .map(|chunk| chunk.delta_text) - .collect::>() - .join(""); + let mut aggregated = String::with_capacity(aggregated_bytes); + for (_, delta) in chunks { + aggregated.push_str(&delta); + } if aggregated.trim().is_empty() { - None + Ok(None) } else { - Some(aggregated) + Ok(Some(aggregated)) } } diff --git a/server-rs/crates/spacetime-module/src/ai/tasks.rs b/server-rs/crates/spacetime-module/src/ai/tasks.rs index 66c0909d1..b6c646789 100644 --- a/server-rs/crates/spacetime-module/src/ai/tasks.rs +++ b/server-rs/crates/spacetime-module/src/ai/tasks.rs @@ -1,5 +1,8 @@ use crate::*; -use module_ai::{INITIAL_AI_TASK_VERSION, normalize_optional_text, validate_task_create_input}; +use module_ai::{ + INITIAL_AI_TASK_VERSION, normalize_optional_text, validate_ai_task_snapshot_memory_limits, + validate_task_create_input, +}; #[spacetimedb::table( accessor = ai_task, @@ -133,6 +136,7 @@ fn create_ai_task_tx( } let task_snapshot = build_ai_task_snapshot_from_create_input(&input); + validate_ai_task_snapshot_memory_limits(&task_snapshot).map_err(str::to_string)?; ctx.db.ai_task().insert(build_ai_task_row(&task_snapshot)); replace_ai_task_stages(ctx, &task_snapshot.task_id, &task_snapshot.stages); emit_ai_task_event( @@ -187,7 +191,9 @@ fn complete_ai_task_tx( snapshot.updated_at_micros = input.completed_at_micros; snapshot.version += 1; + validate_ai_task_snapshot_memory_limits(&snapshot).map_err(str::to_string)?; persist_ai_task_snapshot(ctx, &snapshot)?; + delete_ai_text_chunks_for_task(ctx, &snapshot.task_id); emit_ai_task_event( ctx, &snapshot, @@ -218,7 +224,9 @@ fn fail_ai_task_tx( snapshot.updated_at_micros = input.completed_at_micros; snapshot.version += 1; + validate_ai_task_snapshot_memory_limits(&snapshot).map_err(str::to_string)?; persist_ai_task_snapshot(ctx, &snapshot)?; + delete_ai_text_chunks_for_task(ctx, &snapshot.task_id); emit_ai_task_event( ctx, &snapshot, @@ -243,7 +251,9 @@ fn cancel_ai_task_tx( snapshot.updated_at_micros = input.completed_at_micros; snapshot.version += 1; + validate_ai_task_snapshot_memory_limits(&snapshot).map_err(str::to_string)?; persist_ai_task_snapshot(ctx, &snapshot)?; + delete_ai_text_chunks_for_task(ctx, &snapshot.task_id); emit_ai_task_event( ctx, &snapshot, diff --git a/server-rs/crates/spacetime-module/src/auth/procedures.rs b/server-rs/crates/spacetime-module/src/auth/procedures.rs index 5823f5450..9f181c018 100644 --- a/server-rs/crates/spacetime-module/src/auth/procedures.rs +++ b/server-rs/crates/spacetime-module/src/auth/procedures.rs @@ -1,4 +1,7 @@ use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; use super::tables::{ AuthIdentity, AuthStoreProjectionMeta, RefreshSession, UserAccount, auth_identity, @@ -13,6 +16,9 @@ pub struct AuthStoreProjectionView { pub users: Vec, pub identities: Vec, pub refresh_sessions: Vec, + pub phone_codes: Vec, + pub wechat_states: Vec, + pub base_updated_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -56,6 +62,31 @@ pub struct AuthStoreProjectionRefreshSession { pub last_seen_at: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] +pub struct AuthStoreProjectionPhoneCode { + pub phone_number: String, + pub scene: String, + pub verify_code_hash: String, + pub expires_at: String, + pub last_sent_at: String, + pub failed_attempts: u32, + pub provider_out_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] +pub struct AuthStoreProjectionWechatState { + pub wechat_state_id: String, + pub state_token: String, + pub redirect_path: String, + pub scene: String, + pub request_user_agent: Option, + pub bind_user_id: Option, + pub expires_at: String, + pub consumed_at: Option, + pub created_at: String, + pub updated_at: String, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct AuthStoreProjectionSyncRecord { pub imported_user_count: u32, @@ -77,12 +108,82 @@ pub struct AuthStoreProjectionSyncProcedureResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct AuthSessionValidationInput { + pub user_id: String, + pub session_id: String, + pub token_version: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct AuthSessionValidationProcedureResult { + pub active: bool, + pub error_message: Option, +} + +#[spacetimedb::procedure] +pub fn validate_auth_session( + ctx: &mut ProcedureContext, + input: AuthSessionValidationInput, +) -> AuthSessionValidationProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_auth_service_identity(tx, caller)?; + validate_auth_session_tx(tx, input.clone()) + }) { + Ok(active) => AuthSessionValidationProcedureResult { + active, + error_message: None, + }, + Err(message) => AuthSessionValidationProcedureResult { + active: false, + error_message: Some(message), + }, + } +} + +fn validate_auth_session_tx( + ctx: &ReducerContext, + input: AuthSessionValidationInput, +) -> Result { + let Some(user) = ctx.db.user_account().user_id().find(&input.user_id) else { + return Ok(false); + }; + if user.token_version != input.token_version { + return Ok(false); + } + + let Some(session) = ctx + .db + .refresh_session() + .session_id() + .find(&input.session_id) + else { + return Ok(false); + }; + if session.user_id != input.user_id || session.revoked_at.is_some() { + return Ok(false); + } + + let expires_at = OffsetDateTime::parse(&session.expires_at, &Rfc3339) + .map_err(|_| "refresh session 过期时间格式非法".to_string())?; + let now = OffsetDateTime::from_unix_timestamp_nanos( + i128::from(ctx.timestamp.to_micros_since_unix_epoch()) * 1_000, + ) + .map_err(|_| "SpacetimeDB 当前时间超出认证时间范围".to_string())?; + Ok(expires_at > now) +} + #[spacetimedb::procedure] pub fn sync_auth_store_projection( ctx: &mut ProcedureContext, input: AuthStoreProjectionView, ) -> AuthStoreProjectionSyncProcedureResult { - match ctx.try_with_tx(|tx| sync_auth_store_projection_tx(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_auth_service_identity(tx, caller)?; + sync_auth_store_projection_tx(tx, input.clone()) + }) { Ok(record) => AuthStoreProjectionSyncProcedureResult { ok: true, record: Some(record), @@ -100,7 +201,11 @@ pub fn sync_auth_store_projection( pub fn export_auth_store_projection_from_tables( ctx: &mut ProcedureContext, ) -> AuthStoreProjectionProcedureResult { - match ctx.try_with_tx(|tx| export_auth_store_projection_from_tables_tx(tx)) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_auth_service_identity(tx, caller)?; + export_auth_store_projection_from_tables_tx(tx) + }) { Ok(record) => AuthStoreProjectionProcedureResult { ok: true, record: Some(record), @@ -114,10 +219,34 @@ pub fn export_auth_store_projection_from_tables( } } +fn require_auth_service_identity( + ctx: &ReducerContext, + caller: crate::Identity, +) -> Result<(), String> { + crate::editor_project_storage::require_editor_generation_runtime_service_identity(ctx, caller) + .map_err(|_| "当前 identity 无权调用认证服务".to_string()) +} + fn sync_auth_store_projection_tx( ctx: &ReducerContext, input: AuthStoreProjectionView, ) -> Result { + let current_updated_at_micros = ctx + .db + .auth_store_projection_meta() + .meta_id() + .find(&AUTH_STORE_PROJECTION_META_ID.to_string()) + .map(|row| row.updated_at.to_micros_since_unix_epoch()); + ensure_auth_projection_base_version( + input.base_updated_at_micros, + current_updated_at_micros.unwrap_or_default(), + )?; + ensure_newer_auth_projection_version(current_updated_at_micros, input.updated_at_micros)?; + let phone_codes_json = serde_json::to_string(&input.phone_codes) + .map_err(|error| format!("序列化短信验证码投影失败:{error}"))?; + let wechat_states_json = serde_json::to_string(&input.wechat_states) + .map_err(|error| format!("序列化微信授权 state 投影失败:{error}"))?; + let user_ids = input .users .iter() @@ -246,7 +375,12 @@ fn sync_auth_store_projection_tx( imported_refresh_session_count += 1; } - upsert_auth_projection_meta(ctx, input.updated_at_micros); + upsert_auth_projection_meta( + ctx, + input.updated_at_micros, + phone_codes_json, + wechat_states_json, + ); Ok(AuthStoreProjectionSyncRecord { imported_user_count, @@ -255,16 +389,66 @@ fn sync_auth_store_projection_tx( }) } +/// Full projections are emitted by API-local auth worksets. The base version +/// is read immediately before the write and checked in this same transaction, +/// so a stale API node cannot replace data written by another node meanwhile. +/// The timestamp remains a diagnostic/monotonic watermark for accepted writes. +fn ensure_auth_projection_base_version( + expected_updated_at_micros: i64, + current_updated_at_micros: i64, +) -> Result<(), String> { + if expected_updated_at_micros != current_updated_at_micros { + return Err(format!( + "认证投影基线版本冲突:请求基线 {expected_updated_at_micros} 不等于当前版本 {current_updated_at_micros}" + )); + } + + Ok(()) +} + +fn ensure_newer_auth_projection_version( + current_updated_at_micros: Option, + incoming_updated_at_micros: i64, +) -> Result<(), String> { + if let Some(current_updated_at_micros) = current_updated_at_micros { + if incoming_updated_at_micros <= current_updated_at_micros { + return Err(format!( + "认证投影版本冲突:请求版本 {incoming_updated_at_micros} 不晚于当前版本 {current_updated_at_micros}" + )); + } + } + + Ok(()) +} + fn export_auth_store_projection_from_tables_tx( ctx: &ReducerContext, ) -> Result { - let updated_at_micros = ctx + let meta = ctx .db .auth_store_projection_meta() .meta_id() - .find(&AUTH_STORE_PROJECTION_META_ID.to_string()) + .find(&AUTH_STORE_PROJECTION_META_ID.to_string()); + let updated_at_micros = meta + .as_ref() .map(|row| row.updated_at.to_micros_since_unix_epoch()) .unwrap_or(0); + let phone_codes = meta + .as_ref() + .and_then(|row| row.phone_codes_json.as_deref()) + .filter(|value| !value.trim().is_empty()) + .map(serde_json::from_str) + .transpose() + .map_err(|error| format!("解析短信验证码投影失败:{error}"))? + .unwrap_or_default(); + let wechat_states = meta + .as_ref() + .and_then(|row| row.wechat_states_json.as_deref()) + .filter(|value| !value.trim().is_empty()) + .map(serde_json::from_str) + .transpose() + .map_err(|error| format!("解析微信授权 state 投影失败:{error}"))? + .unwrap_or_default(); let users = ctx .db .user_account() @@ -317,10 +501,13 @@ fn export_auth_store_projection_from_tables_tx( .collect(); Ok(AuthStoreProjectionView { + base_updated_at_micros: updated_at_micros, updated_at_micros, users, identities, refresh_sessions, + phone_codes, + wechat_states, }) } @@ -363,7 +550,12 @@ fn delete_missing_refresh_sessions( } } -fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) { +fn upsert_auth_projection_meta( + ctx: &ReducerContext, + updated_at_micros: i64, + phone_codes_json: String, + wechat_states_json: String, +) { let meta_id = AUTH_STORE_PROJECTION_META_ID.to_string(); if ctx .db @@ -382,6 +574,8 @@ fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) { .insert(AuthStoreProjectionMeta { meta_id, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), + phone_codes_json: Some(phone_codes_json), + wechat_states_json: Some(wechat_states_json), }); } @@ -465,4 +659,19 @@ mod tests { ); assert_eq!(created_at, "2026-07-01T00:00:00Z"); } + + #[test] + fn auth_projection_version_must_advance_monotonically() { + assert!(ensure_newer_auth_projection_version(None, 1).is_ok()); + assert!(ensure_newer_auth_projection_version(Some(10), 11).is_ok()); + assert!(ensure_newer_auth_projection_version(Some(10), 10).is_err()); + assert!(ensure_newer_auth_projection_version(Some(10), 9).is_err()); + } + + #[test] + fn auth_projection_base_version_must_match_current_version() { + assert!(ensure_auth_projection_base_version(0, 0).is_ok()); + assert!(ensure_auth_projection_base_version(10, 10).is_ok()); + assert!(ensure_auth_projection_base_version(9, 10).is_err()); + } } diff --git a/server-rs/crates/spacetime-module/src/auth/tables.rs b/server-rs/crates/spacetime-module/src/auth/tables.rs index e9f6e6c11..49251e549 100644 --- a/server-rs/crates/spacetime-module/src/auth/tables.rs +++ b/server-rs/crates/spacetime-module/src/auth/tables.rs @@ -5,6 +5,10 @@ pub struct AuthStoreProjectionMeta { #[primary_key] pub(crate) meta_id: String, pub(crate) updated_at: Timestamp, + #[default(None::)] + pub(crate) phone_codes_json: Option, + #[default(None::)] + pub(crate) wechat_states_json: Option, } #[spacetimedb::table( diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index 9cfa4fa13..5e93b162c 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -23,6 +23,7 @@ const MAX_EXTERNAL_GENERATION_REQUEST_PROMPT_CHARS: usize = 2_048; const MAX_EXTERNAL_GENERATION_ERROR_MESSAGE_CHARS: usize = 2_048; const MAX_EXTERNAL_GENERATION_WARNING_MESSAGE_CHARS: usize = 2_048; const MAX_EXTERNAL_GENERATION_MAINTENANCE_BATCH_SIZE: u32 = 25; +const EXTERNAL_GENERATION_EVENT_DELETE_BATCH_SIZE: usize = 256; const INLINE_MEDIA_REMOVED_PLACEHOLDER: &str = "[inline-media-removed]"; const INLINE_MEDIA_ERROR_REDACTED_MESSAGE: &str = "外部生成失败(错误详情含内联媒体引用,已省略)"; const INLINE_MEDIA_WARNING_REDACTED_MESSAGE: &str = @@ -97,6 +98,10 @@ pub struct ExternalGenerationJob { accessor = by_external_generation_job_event_job_id, btree(columns = [job_id, created_at]) ), + index( + accessor = by_external_generation_job_event_job_id_only, + btree(columns = [job_id]) + ), index( accessor = by_external_generation_job_event_owner, btree(columns = [owner_user_id, created_at]) @@ -378,6 +383,29 @@ pub struct ExternalGenerationJobPayloadCompactionProcedureResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobRetentionInput { + pub source_module: String, + pub limit: u32, + pub cursor_job_id: Option, + pub completed_before_micros: i64, + pub dry_run: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobRetentionProcedureResult { + pub ok: bool, + pub dry_run: bool, + pub scanned_count: u64, + pub selected_count: u32, + pub deleted_job_count: u32, + pub deleted_summary_count: u32, + pub deleted_event_count: u32, + pub next_cursor_job_id: Option, + pub has_more: bool, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct ExternalGenerationQueueStatsSnapshot { pub pending_count: u32, @@ -676,6 +704,21 @@ pub fn compact_external_generation_job_payloads_and_return( } } +#[spacetimedb::procedure] +pub fn prune_external_generation_job_history_and_return( + ctx: &mut ProcedureContext, + input: ExternalGenerationJobRetentionInput, +) -> ExternalGenerationJobRetentionProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::migration::require_migration_operator(tx, caller)?; + prune_external_generation_job_history_tx(tx, input.clone()) + }) { + Ok(result) => result, + Err(message) => failed_external_generation_job_retention_result(input.dry_run, message), + } +} + #[spacetimedb::procedure] pub fn get_external_generation_queue_stats_and_return( ctx: &mut ProcedureContext, @@ -888,6 +931,8 @@ fn finalize_external_generation_job_after_lease_exhaustion( row.price_mud_points, failed_at, "final_attempt_lease_expired", + &row.job_kind, + &row.source_entity_id, )?; let row = mark_external_generation_job_lease_exhausted(row, failed_at, refund_ledger_id); persist_external_generation_job_row(ctx, row.clone()); @@ -1367,6 +1412,124 @@ fn compact_external_generation_job_payloads_tx( }) } +fn prune_external_generation_job_history_tx( + ctx: &ReducerContext, + input: ExternalGenerationJobRetentionInput, +) -> Result { + let source_module = input.source_module.trim().to_string(); + validate_required("external_generation_job.source_module", &source_module)?; + let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); + if input.completed_before_micros > now_micros { + return Err( + "external_generation_job.completed_before_micros 不能晚于数据库当前时间".to_string(), + ); + } + + let cursor_job_id = input + .cursor_job_id + .as_deref() + .and_then(normalize_optional_text); + let limit = input + .limit + .clamp(1, MAX_EXTERNAL_GENERATION_MAINTENANCE_BATCH_SIZE) as usize; + let cursor_range = external_generation_job_maintenance_cursor_range(cursor_job_id.as_deref()); + let cursor_to_skip = cursor_job_id.clone(); + // 若 cursor 对应的任务仍存在,说明上一事务只删完了事件的一部分,或 dry-run + // 尚未执行 apply;下一次必须包含该任务继续清理,成功删除后它会自然消失。 + let include_existing_cursor = cursor_job_id.as_deref().is_some_and(|cursor| { + ctx.db + .external_generation_job() + .job_id() + .find(&cursor.to_string()) + .is_some() + }); + let rows = ctx + .db + .external_generation_job() + .by_external_generation_job_source_cursor() + .filter((source_module.as_str(), cursor_range)) + .filter(move |row| { + cursor_to_skip + .as_deref() + .is_none_or(|cursor| row.job_id != cursor || include_existing_cursor) + }); + let (job_ids, next_cursor_job_id, has_more, scanned_count) = + select_external_generation_job_ids_for_maintenance(rows, limit, |row| { + ctx.db + .external_generation_job_summary() + .job_id() + .find(&row.job_id) + .is_some_and(|summary| { + is_external_generation_job_retention_candidate( + row, + &summary, + &source_module, + input.completed_before_micros, + ) + }) + }); + + let mut deleted_job_count = 0u32; + let mut deleted_summary_count = 0u32; + let mut deleted_event_count = 0u32; + let mut pending_event_cursor_job_id = None; + if !input.dry_run { + for job_id in &job_ids { + let Some(row) = ctx.db.external_generation_job().job_id().find(job_id) else { + continue; + }; + let Some(summary) = ctx + .db + .external_generation_job_summary() + .job_id() + .find(job_id) + else { + continue; + }; + if !is_external_generation_job_retention_candidate( + &row, + &summary, + &source_module, + input.completed_before_micros, + ) { + continue; + } + + let (deleted_for_job, has_more_events) = + delete_external_generation_job_events_for_job(ctx, job_id); + deleted_event_count = deleted_event_count.saturating_add(deleted_for_job); + if has_more_events { + pending_event_cursor_job_id = Some(job_id.clone()); + break; + } + ctx.db + .external_generation_job_summary() + .job_id() + .delete(job_id); + deleted_summary_count = deleted_summary_count.saturating_add(1); + ctx.db.external_generation_job().job_id().delete(job_id); + deleted_job_count = deleted_job_count.saturating_add(1); + } + } + + let (next_cursor_job_id, has_more) = pending_event_cursor_job_id + .map(|job_id| (Some(job_id), true)) + .unwrap_or((next_cursor_job_id, has_more)); + + Ok(ExternalGenerationJobRetentionProcedureResult { + ok: true, + dry_run: input.dry_run, + scanned_count, + selected_count: job_ids.len() as u32, + deleted_job_count, + deleted_summary_count, + deleted_event_count, + next_cursor_job_id, + has_more, + error_message: None, + }) +} + fn renew_external_generation_job_lease_tx( ctx: &ReducerContext, input: ExternalGenerationJobRenewLeaseInput, @@ -1437,6 +1600,8 @@ fn fail_external_generation_job_tx( row.price_mud_points, failed_at, "worker_attempt_failed", + &row.job_kind, + &row.source_entity_id, )?; let requested_refund_ledger_id = input .refund_ledger_id @@ -1797,6 +1962,25 @@ fn should_compact_external_generation_job_payloads( }) } +fn is_external_generation_job_retention_candidate( + row: &ExternalGenerationJob, + summary: &ExternalGenerationJobSummary, + source_module: &str, + completed_before_micros: i64, +) -> bool { + row.source_module.trim() == source_module.trim() + && summary.job_id == row.job_id + && summary.status == row.status + && is_external_generation_job_terminal(row) + && is_external_generation_job_summary_terminal(summary) + && summary.notification_acknowledged_at.is_some() + && row + .completed_at + .unwrap_or(row.updated_at) + .to_micros_since_unix_epoch() + <= completed_before_micros +} + fn external_generation_job_maintenance_cursor_range( cursor_job_id: Option<&str>, ) -> RangeFrom<&str> { @@ -1832,6 +2016,36 @@ fn select_external_generation_job_ids_for_maintenance( ) } +fn delete_external_generation_job_events_for_job( + ctx: &ReducerContext, + job_id: &str, +) -> (u32, bool) { + // 每次 procedure 最多删除一个固定批次;若仍有事件,保留 job/summary,调用方 + // 通过同一个 job cursor 重试,避免单个任务把整段审计历史塞进一个事务写集。 + let event_ids = ctx + .db + .external_generation_job_event() + .by_external_generation_job_event_job_id_only() + .filter(job_id) + .take(EXTERNAL_GENERATION_EVENT_DELETE_BATCH_SIZE + 1) + .map(|event| event.event_id.clone()) + .collect::>(); + let has_more = event_ids.len() > EXTERNAL_GENERATION_EVENT_DELETE_BATCH_SIZE; + let deleted_count = event_ids + .len() + .min(EXTERNAL_GENERATION_EVENT_DELETE_BATCH_SIZE) as u32; + for event_id in event_ids + .into_iter() + .take(EXTERNAL_GENERATION_EVENT_DELETE_BATCH_SIZE) + { + ctx.db + .external_generation_job_event() + .event_id() + .delete(&event_id); + } + (deleted_count, has_more) +} + fn count_external_generation_job_summaries_for_owner( ctx: &ReducerContext, owner_user_id: &str, @@ -2544,6 +2758,24 @@ fn failed_external_generation_job_payload_compaction_result( } } +fn failed_external_generation_job_retention_result( + dry_run: bool, + message: String, +) -> ExternalGenerationJobRetentionProcedureResult { + ExternalGenerationJobRetentionProcedureResult { + ok: false, + dry_run, + scanned_count: 0, + selected_count: 0, + deleted_job_count: 0, + deleted_summary_count: 0, + deleted_event_count: 0, + next_cursor_job_id: None, + has_more: false, + error_message: Some(message), + } +} + fn validate_required(field: &str, value: &str) -> Result<(), String> { if value.trim().is_empty() { return Err(format!("{field} 不能为空")); @@ -3434,6 +3666,86 @@ mod tests { )); } + #[test] + fn retention_only_selects_acknowledged_terminal_rows_before_cutoff() { + let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_COMPLETED); + row.source_module = EXTERNAL_GENERATION_EDITOR_SOURCE_MODULE.to_string(); + row.completed_at = Some(micros(1_000)); + row.updated_at = micros(1_000); + let mut summary = build_external_generation_job_summary_row(&row, None); + summary.notification_acknowledged_at = Some(micros(2_000)); + + assert!(is_external_generation_job_retention_candidate( + &row, + &summary, + EXTERNAL_GENERATION_EDITOR_SOURCE_MODULE, + 1_000, + )); + + summary.notification_acknowledged_at = None; + assert!(!is_external_generation_job_retention_candidate( + &row, + &summary, + EXTERNAL_GENERATION_EDITOR_SOURCE_MODULE, + 1_000, + )); + + summary.notification_acknowledged_at = Some(micros(2_000)); + row.status = EXTERNAL_GENERATION_STATUS_RUNNING.to_string(); + summary.status = EXTERNAL_GENERATION_STATUS_RUNNING.to_string(); + assert!(!is_external_generation_job_retention_candidate( + &row, + &summary, + EXTERNAL_GENERATION_EDITOR_SOURCE_MODULE, + 1_000, + )); + + row.status = EXTERNAL_GENERATION_STATUS_COMPLETED.to_string(); + summary.status = EXTERNAL_GENERATION_STATUS_COMPLETED.to_string(); + row.completed_at = Some(micros(1_001)); + assert!(!is_external_generation_job_retention_candidate( + &row, + &summary, + EXTERNAL_GENERATION_EDITOR_SOURCE_MODULE, + 1_000, + )); + + row.completed_at = Some(micros(1_000)); + row.source_module = "puzzle".to_string(); + assert!(!is_external_generation_job_retention_candidate( + &row, + &summary, + EXTERNAL_GENERATION_EDITOR_SOURCE_MODULE, + 1_000, + )); + } + + #[test] + fn retention_rejects_mismatched_summary_identity_or_status() { + let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_FAILED); + row.source_module = EXTERNAL_GENERATION_EDITOR_SOURCE_MODULE.to_string(); + row.completed_at = Some(micros(1_000)); + let mut summary = build_external_generation_job_summary_row(&row, None); + summary.notification_acknowledged_at = Some(micros(2_000)); + + summary.job_id = "different-job".to_string(); + assert!(!is_external_generation_job_retention_candidate( + &row, + &summary, + EXTERNAL_GENERATION_EDITOR_SOURCE_MODULE, + 1_000, + )); + + summary.job_id = row.job_id.clone(); + summary.status = EXTERNAL_GENERATION_STATUS_CANCELLED.to_string(); + assert!(!is_external_generation_job_retention_candidate( + &row, + &summary, + EXTERNAL_GENERATION_EDITOR_SOURCE_MODULE, + 1_000, + )); + } + #[test] fn maintenance_selector_bounds_scanned_rows_and_advances_by_last_scanned_job() { let rows = (1..=4).map(|index| { diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 4b6993bf7..32e02063c 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -199,6 +199,7 @@ macro_rules! migration_tables { profile_wallet_ledger, profile_wallet_consumption_total, asset_operation_wallet_settlement, + profile_wallet_refund_outbox, profile_wallet_config, analytics_date_dimension, tracking_event, diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index e53d8e6b9..4fd3e8818 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -19,6 +19,8 @@ const PROFILE_RECHARGE_ORDER_EXPIRATION_CHECK_LIMIT_DEFAULT: u32 = 50; const PROFILE_RECHARGE_ORDER_EXPIRATION_CHECK_LIMIT_MAX: u32 = 200; const ASSET_OPERATION_CONSUME_LEDGER_PREFIX: &str = "asset_operation_consume:"; const ASSET_OPERATION_REFUND_LEDGER_PREFIX: &str = "asset_operation_refund:"; +const PROFILE_WALLET_REFUND_OUTBOX_STATUS_PENDING: &str = "pending"; +const PROFILE_WALLET_REFUND_OUTBOX_MAX_BATCH_SIZE: u32 = 100; #[spacetimedb::table(accessor = profile_dashboard_state)] pub struct ProfileDashboardState { @@ -82,6 +84,34 @@ pub struct AssetOperationWalletSettlement { pub(crate) settled_at: Timestamp, } +#[spacetimedb::table( + accessor = profile_wallet_refund_outbox, + index( + accessor = by_profile_wallet_refund_outbox_status_available, + btree(columns = [status, available_at]) + ) +)] +#[derive(Clone)] +pub struct ProfileWalletRefundOutbox { + #[primary_key] + pub(crate) refund_ledger_id: String, + pub(crate) consume_ledger_id: String, + pub(crate) owner_user_id: String, + pub(crate) amount: u64, + pub(crate) created_at: Timestamp, + pub(crate) asset_kind: String, + pub(crate) asset_id: String, + pub(crate) settlement_reason: String, + pub(crate) external_generation_job_id: Option, + pub(crate) external_generation_claim_attempt: Option, + pub(crate) status: String, + pub(crate) available_at: Timestamp, + pub(crate) attempts: u32, + pub(crate) last_error: Option, + pub(crate) last_attempted_at: Option, + pub(crate) last_worker_id: Option, +} + #[spacetimedb::table(accessor = profile_wallet_config)] #[derive(Clone)] pub struct ProfileWalletConfig { @@ -1159,6 +1189,82 @@ pub fn refund_profile_wallet_points_and_return( } } +#[spacetimedb::procedure] +pub fn enqueue_profile_wallet_refund_outbox_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileWalletRefundOutboxEnqueueInput, +) -> RuntimeProfileWalletRefundOutboxProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + enqueue_profile_wallet_refund_outbox_tx( + tx, + input.owner_user_id.clone(), + input.amount, + input.refund_ledger_id.clone(), + input.created_at_micros, + input.asset_kind.clone(), + input.asset_id.clone(), + input.settlement_reason.clone(), + input.external_generation_job_id.clone(), + input.external_generation_claim_attempt, + ) + .map(|enqueued| u32::from(enqueued)) + }) { + Ok(enqueued_count) => RuntimeProfileWalletRefundOutboxProcedureResult { + ok: true, + enqueued_count, + processed_count: 0, + retry_count: 0, + failed_count: 0, + error_message: None, + }, + Err(message) => RuntimeProfileWalletRefundOutboxProcedureResult { + ok: false, + enqueued_count: 0, + processed_count: 0, + retry_count: 0, + failed_count: 0, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn process_profile_wallet_refund_outbox_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileWalletRefundOutboxProcessInput, +) -> RuntimeProfileWalletRefundOutboxProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + process_profile_wallet_refund_outbox_tx(tx, input.clone()) + }) { + Ok((processed_count, retry_count, failed_count)) => { + RuntimeProfileWalletRefundOutboxProcedureResult { + ok: true, + enqueued_count: 0, + processed_count, + retry_count, + failed_count, + error_message: None, + } + } + Err(message) => RuntimeProfileWalletRefundOutboxProcedureResult { + ok: false, + enqueued_count: 0, + processed_count: 0, + retry_count: 0, + failed_count: 0, + error_message: Some(message), + }, + } +} + // play stats 与 dashboard 共用 dashboard projection 的 total_play_time / updated_at,避免 Axum 侧拼装。 #[cfg(any())] #[spacetimedb::procedure] @@ -2827,6 +2933,45 @@ mod tests { ); } + #[test] + fn refund_outbox_idempotency_allows_recovery_reason_to_change() { + let row = ProfileWalletRefundOutbox { + refund_ledger_id: "asset_operation_refund:external_generation_job:job-1:attempt:1" + .to_string(), + consume_ledger_id: "asset_operation_consume:external_generation_job:job-1:attempt:1" + .to_string(), + owner_user_id: "user-1".to_string(), + amount: 37, + created_at: Timestamp::from_micros_since_unix_epoch(1), + asset_kind: "editor-image".to_string(), + asset_id: "asset-1".to_string(), + settlement_reason: "worker_attempt_failed".to_string(), + external_generation_job_id: Some("job-1".to_string()), + external_generation_claim_attempt: Some(1), + status: PROFILE_WALLET_REFUND_OUTBOX_STATUS_PENDING.to_string(), + available_at: Timestamp::from_micros_since_unix_epoch(1), + attempts: 0, + last_error: None, + last_attempted_at: None, + last_worker_id: None, + }; + + assert!( + validate_profile_wallet_refund_outbox_fact( + &row, + &row.refund_ledger_id, + &row.consume_ledger_id, + &row.owner_user_id, + row.amount, + &row.asset_kind, + &row.asset_id, + Some("job-1"), + Some(1), + ) + .is_ok() + ); + } + #[test] fn wallet_idempotent_replay_requires_matching_user_amount_and_source() { let existing = asset_operation_wallet_ledger( @@ -9125,6 +9270,250 @@ pub(crate) fn grant_profile_wallet_points_with_metadata( ) } +pub(crate) fn enqueue_profile_wallet_refund_outbox_tx( + ctx: &ReducerContext, + owner_user_id: String, + amount: u64, + refund_ledger_id: String, + created_at_micros: i64, + asset_kind: String, + asset_id: String, + settlement_reason: String, + external_generation_job_id: Option, + external_generation_claim_attempt: Option, +) -> Result { + let owner_user_id = owner_user_id.trim().to_string(); + if owner_user_id.is_empty() { + return Err("资产操作退款用户不能为空".to_string()); + } + if amount == 0 { + return Err("资产操作退款金额必须大于 0".to_string()); + } + let refund_ledger_id = refund_ledger_id.trim().to_string(); + let consume_ledger_id = asset_operation_consume_ledger_id(&refund_ledger_id)?; + let asset_kind = asset_kind.trim().to_string(); + if asset_kind.is_empty() { + return Err("资产操作退款 asset_kind 不能为空".to_string()); + } + let asset_id = asset_id.trim().to_string(); + if asset_id.is_empty() { + return Err("资产操作退款 asset_id 不能为空".to_string()); + } + let settlement_reason = settlement_reason.trim().to_string(); + if settlement_reason.is_empty() { + return Err("资产操作退款 settlement_reason 不能为空".to_string()); + } + let external_generation_job_id = normalize_optional_text(external_generation_job_id); + if external_generation_job_id.is_some() != external_generation_claim_attempt.is_some() { + return Err("资产操作退款 outbox 任务与 attempt 必须成对提供".to_string()); + } + let created_at = Timestamp::from_micros_since_unix_epoch(created_at_micros); + let disposition = resolve_asset_operation_refund_disposition_from_ledger( + ctx, + owner_user_id.as_str(), + i64::try_from(amount).map_err(|_| "资产操作退款金额超出范围".to_string())?, + refund_ledger_id.as_str(), + )?; + + if matches!(disposition, AssetOperationRefundDisposition::Noop) { + if let Some(existing) = ctx + .db + .profile_wallet_refund_outbox() + .refund_ledger_id() + .find(&refund_ledger_id) + { + validate_profile_wallet_refund_outbox_fact( + &existing, + &refund_ledger_id, + &consume_ledger_id, + &owner_user_id, + amount, + &asset_kind, + &asset_id, + external_generation_job_id.as_deref(), + external_generation_claim_attempt, + )?; + ctx.db + .profile_wallet_refund_outbox() + .refund_ledger_id() + .delete(&refund_ledger_id); + } + return Ok(false); + } + if matches!(disposition, AssetOperationRefundDisposition::RecordIntent) { + record_asset_operation_wallet_settlement( + ctx, + owner_user_id.as_str(), + i64::try_from(amount).map_err(|_| "资产操作退款金额超出范围".to_string())?, + refund_ledger_id.as_str(), + created_at, + )?; + // consume 尚不可见时,settlement 是取消 intent;不应再创建会被 worker + // 误认为可退款的 outbox 行。迟到 consume 会在同一 resolver 中被拒绝。 + return Ok(false); + } + + let existing = ctx + .db + .profile_wallet_refund_outbox() + .refund_ledger_id() + .find(&refund_ledger_id); + if let Some(existing) = existing { + validate_profile_wallet_refund_outbox_fact( + &existing, + &refund_ledger_id, + &consume_ledger_id, + &owner_user_id, + amount, + &asset_kind, + &asset_id, + external_generation_job_id.as_deref(), + external_generation_claim_attempt, + )?; + let mut existing = existing; + existing.status = PROFILE_WALLET_REFUND_OUTBOX_STATUS_PENDING.to_string(); + existing.available_at = ctx.timestamp; + existing.last_error = None; + ctx.db + .profile_wallet_refund_outbox() + .refund_ledger_id() + .update(existing); + return Ok(false); + } + + ctx.db + .profile_wallet_refund_outbox() + .insert(ProfileWalletRefundOutbox { + refund_ledger_id, + consume_ledger_id, + owner_user_id, + amount, + created_at, + asset_kind, + asset_id, + settlement_reason, + external_generation_job_id, + external_generation_claim_attempt, + status: PROFILE_WALLET_REFUND_OUTBOX_STATUS_PENDING.to_string(), + available_at: ctx.timestamp, + attempts: 0, + last_error: None, + last_attempted_at: None, + last_worker_id: None, + }); + Ok(true) +} + +fn validate_profile_wallet_refund_outbox_fact( + row: &ProfileWalletRefundOutbox, + expected_refund_ledger_id: &str, + expected_consume_ledger_id: &str, + expected_owner_user_id: &str, + expected_amount: u64, + expected_asset_kind: &str, + expected_asset_id: &str, + expected_external_generation_job_id: Option<&str>, + expected_external_generation_claim_attempt: Option, +) -> Result<(), String> { + if row.refund_ledger_id != expected_refund_ledger_id + || row.consume_ledger_id != expected_consume_ledger_id + || row.owner_user_id != expected_owner_user_id + || row.amount != expected_amount + { + return Err("资产操作退款 outbox 金融事实不匹配".to_string()); + } + if row.asset_kind != expected_asset_kind || row.asset_id != expected_asset_id { + return Err("资产操作退款 outbox 资源事实不匹配".to_string()); + } + // settlement_reason is diagnostic context, not an idempotency fact. A failed + // attempt can be observed first by its failure transaction and later by stale + // attempt recovery, which legitimately use different reason labels. + if row.external_generation_job_id.as_deref() != expected_external_generation_job_id + || row.external_generation_claim_attempt != expected_external_generation_claim_attempt + { + return Err("资产操作退款 outbox 任务 attempt 不匹配".to_string()); + } + Ok(()) +} + +fn process_profile_wallet_refund_outbox_tx( + ctx: &ReducerContext, + input: RuntimeProfileWalletRefundOutboxProcessInput, +) -> Result<(u32, u32, u32), String> { + let worker_id = input.worker_id.trim(); + if worker_id.is_empty() { + return Err("退款 outbox worker_id 不能为空".to_string()); + } + let limit = input + .limit + .clamp(1, PROFILE_WALLET_REFUND_OUTBOX_MAX_BATCH_SIZE); + let now = ctx.timestamp; + let mut rows = ctx + .db + .profile_wallet_refund_outbox() + .by_profile_wallet_refund_outbox_status_available() + .filter(&PROFILE_WALLET_REFUND_OUTBOX_STATUS_PENDING.to_string()) + .filter(|row| row.available_at <= now) + .collect::>(); + rows.sort_by(|left, right| { + left.available_at + .cmp(&right.available_at) + .then_with(|| left.created_at.cmp(&right.created_at)) + .then_with(|| left.refund_ledger_id.cmp(&right.refund_ledger_id)) + }); + + let mut processed_count: u32 = 0; + let mut retry_count: u32 = 0; + let mut failed_count: u32 = 0; + for mut row in rows.into_iter().take(limit as usize) { + row.attempts = row.attempts.saturating_add(1); + row.last_attempted_at = Some(now); + row.last_worker_id = Some(worker_id.to_string()); + let metadata_json = serde_json::json!({ + "externalGenerationJobId": &row.external_generation_job_id, + "externalGenerationClaimAttempt": row.external_generation_claim_attempt, + "assetKind": &row.asset_kind, + "assetId": &row.asset_id, + "settlementReason": &row.settlement_reason, + "refundOutboxWorkerId": worker_id, + }) + .to_string(); + let result = apply_profile_wallet_adjustment( + ctx, + RuntimeProfileWalletAdjustmentInput { + user_id: row.owner_user_id.clone(), + amount: row.amount, + ledger_id: row.refund_ledger_id.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + metadata_json, + }, + RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, + false, + ); + match result { + Ok(_) => { + ctx.db + .profile_wallet_refund_outbox() + .refund_ledger_id() + .delete(&row.refund_ledger_id); + processed_count = processed_count.saturating_add(1); + } + Err(error) => { + row.available_at = + now + std::time::Duration::from_secs(2u64.saturating_pow(row.attempts.min(10))); + row.last_error = Some(error); + ctx.db + .profile_wallet_refund_outbox() + .refund_ledger_id() + .update(row); + retry_count = retry_count.saturating_add(1); + failed_count = failed_count.saturating_add(1); + } + } + } + Ok((processed_count, retry_count, failed_count)) +} + fn apply_profile_wallet_adjustment( ctx: &ReducerContext, input: RuntimeProfileWalletAdjustmentInput, @@ -9242,6 +9631,8 @@ pub(crate) fn settle_external_generation_attempt_refund( amount: u64, settled_at: Timestamp, settlement_reason: &str, + asset_kind: &str, + asset_id: &str, ) -> Result, String> { if amount == 0 { return Ok(None); @@ -9250,22 +9641,17 @@ pub(crate) fn settle_external_generation_attempt_refund( "{ASSET_OPERATION_REFUND_LEDGER_PREFIX}external_generation_job:{}:attempt:{attempt}", job_id.trim() ); - apply_profile_wallet_adjustment( + enqueue_profile_wallet_refund_outbox_tx( ctx, - RuntimeProfileWalletAdjustmentInput { - user_id: user_id.to_string(), - amount, - ledger_id: refund_ledger_id.clone(), - created_at_micros: settled_at.to_micros_since_unix_epoch(), - metadata_json: serde_json::json!({ - "externalGenerationJobId": job_id.trim(), - "claimAttempt": attempt, - "settlementReason": settlement_reason, - }) - .to_string(), - }, - RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, - false, + user_id.to_string(), + amount, + refund_ledger_id.clone(), + settled_at.to_micros_since_unix_epoch(), + asset_kind.to_string(), + asset_id.to_string(), + settlement_reason.to_string(), + Some(job_id.trim().to_string()), + Some(attempt), )?; Ok(Some(refund_ledger_id)) }