29 Commits

Author SHA1 Message Date
6984af782c 更新 API meta 版本号
将 shared-contracts 与前端共享 API_VERSION 更新为 2026-06-16

同步 healthz 响应头断言与 API envelope 测试夹具

补充 api-server 文档中的 API_VERSION 来源说明
2026-06-16 16:37:40 +08:00
9ab87956f8 忽略 Codegraph 本地缓存
新增 .codegraph 本地目录忽略规则
2026-06-16 16:13:55 +08:00
2dcdd90c37 Merge branch 'codex/rag' 2026-06-16 16:10:33 +08:00
15a527d7f4 整理项目记忆与Agent RAG入口
迁移项目共享记忆到 docs/project-memory,保留 .hermes 仅作为工具目录

新增 Agent 本地 RAG 索引与上下文包检索脚本

记录 RAG 依赖只安装到 .rag/runtime 并加入忽略规则

同步文档与检查脚本中的项目记忆路径
2026-06-16 16:06:54 +08:00
264453a714 更新 SpacetimeDB 本地技能
更新 SpacetimeDB CLI、概念和 Rust 模块 skill 到 2.5 口径

删除 TypeScript、C# 和 Unity SpacetimeDB 本地 skill

同步 AGENTS 与 Hermes 决策记录中的 skill 维护范围

补充 2.2.0 到 2.5.0 项目相关差异和 event table 规则
2026-06-16 11:45:14 +08:00
767da0164a 修复隐藏拼图作品进入通关推荐
收口拼图公开消费路径的 Published + visible 判断

拦截隐藏拼图的公开详情、互动和正式运行态入口

补充隐藏拼图推荐候选回归测试

更新后端契约文档和团队踩坑记录
2026-06-15 22:42:38 +08:00
a51e63415f 收口生成队列与图片预览
将外部生成队列概览移到我的页签展示

移除生成页和进度页中的队列概览区域

新增全屏黑底图片预览器并支持缩放和边界拖拽

补充队列概览和图片预览的聚焦测试

同步更新玩法链路、运维、UI Kit 和团队共享记忆文档
2026-06-13 22:25:22 +08:00
kdletters
bdf99468e7 修复推荐页滑动切换回弹
为推荐页滑动提交后的 rail 复位增加无过渡 resetting 状态

补充推荐滑动状态模型测试覆盖 resetting 类名

补齐推荐页交互测试中的小程序运行态 mock
2026-06-13 20:13:44 +08:00
5a1c1c88dd 修复拼图结果页图片预览层级
关卡缩略图改为完整显示,避免生成图被裁切

关卡详情内主图预览支持提高层级,避免被详情弹窗遮挡

补充拼图结果页聚焦测试与 Hermes 踩坑记录
2026-06-13 16:15:38 +08:00
38babc592d Merge remote-tracking branch 'origin/master'
# Conflicts:
#	scripts/jenkins-server-provision.sh
2026-06-13 16:03:14 +08:00
660abff773 升级SpacetimeDB到2.5.0
将SpacetimeDB相关Rust依赖精确锁定到2.5.0

同步本地CLI校验、生成绑定、容器与服务器provision默认版本

在文档和团队共享记忆中补充版本不匹配先升级再重试提醒

补齐拼消消生成中状态常量以恢复模块生成
2026-06-13 15:44:35 +08:00
cd49cb0106 修复otelcol无限重启 2026-06-12 23:42:23 +08:00
b7fd36747d 合并外部生成Worker队列扩展 2026-06-12 23:19:54 +08:00
951caac32d 扩展外部生成Worker队列
新增外部生成队列概览和单任务状态契约

将跳一跳、拼消消、敲木鱼图片生成动作接入worker队列

前端生成等待页展示当前任务和队列数量

更新外部生成worker运维文档和团队决策记录
2026-06-12 23:15:55 +08:00
3bccfd1a83 Merge remote-tracking branch 'origin/master' into codex/external-generation-worker-scaling 2026-06-12 16:11:12 +08:00
fe30396544 合并泥点弹窗透明修复
# Conflicts:
#	src/components/common/PublishShareModal.test.tsx
#	src/components/common/PublishShareModal.tsx
#	src/index.test.ts
2026-06-12 15:35:19 +08:00
2251fa2f8e 补齐外部生成服务OpenSSL路径
为worker与controller systemd单元补齐LD_LIBRARY_PATH

避免服务器动态链接OpenSSL失败
2026-06-12 15:24:11 +08:00
4a6c126366 完善外部生成Worker动态扩缩容
新增外部生成controller进程角色与systemd服务

补齐队列统计procedure与spacetime-client绑定

更新生产部署脚本、健康巡检和server provision的worker/controller口径

新增容器worker smoke脚本并同步运维文档与团队记忆
2026-06-12 15:21:35 +08:00
69815d918a 合并最新 origin/master
补合 master 最新小程序分享、开发脚本与 server-manager-panel 更新

保留外部生成 worker 分支已有改动,继续本地合并不推送
2026-06-11 23:14:26 +08:00
f87ae3f915 合并 origin/master
合入 master 的钱包退款 outbox、拼图后台编译互斥与公开链路更新

保留当前分支外部生成 worker 队列语义,并对齐拼图首图 claim 释放顺序
2026-06-11 23:06:41 +08:00
kdletters
21add3dcbc Merge remote-tracking branch 'origin/master' 2026-06-11 22:51:26 +08:00
kdletters
1dd58a3d66 合并分享链路重构到主分支
合入通用作品分享卡片与小程序直达路径
合入推荐页当前作品系统分享参数同步
合入小程序九宫切图与相关测试

# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
#	docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
#	src/components/custom-world-home/CustomWorldCreationHub.tsx
#	src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/RpgEntryHomeView.tsx
2026-06-11 22:50:32 +08:00
kdletters
d78c11d5b7 修复小程序推荐页系统分享直达作品
同步推荐页当前作品到小程序原生分享目标
保留小程序系统分享路径中的公开作品参数
补充小程序分享目标解析与前端消息发送测试
2026-06-11 22:30:23 +08:00
c5763fdf25 重构作品分享链路
统一发布分享弹窗为作品分享卡片

支持下载分享卡与小程序九宫切图保存

小程序复制链接改为可直达作品详情的 web-view 路径

修复本地 dev Rust 构建绕过损坏 sccache

补充分享链路与 dev 启动文档和测试
2026-06-11 21:32:29 +08:00
31ad55b0cf 合并 master 并保留外部生成 worker 模式
合入 master 的生产健康巡检、JumpHop 和 SpacetimeDB 更新
保留外部生成 worker、队列/内联模式与 lease guard 口径
合并 Server-Provision 工具复用、health patrol 和外部生成 worker systemd 配置
补齐 SpacetimeDB 生成绑定并通过本地检查
2026-06-10 21:26:53 +08:00
4f86c1a75b 合并 master 并保留外部生成 worker 模式
合入 master 的拼消消、微信能力、OpenSSL 3.2 和 SpacetimeDB 2.4.1 更新
保留外部内容生成 queue/inline、worker lease 与动态扩缩容口径
补齐拼图后台图片生成队列轮询和运行态返回恢复
同步容器、生产运维和 Hermes 共享记忆中的 worker 文档
2026-06-09 16:55:32 +08:00
4bb6d0bd1e feat: add inline external generation mode 2026-06-07 00:56:53 +08:00
853d1db618 fix: make container worker validation reproducible 2026-06-05 19:01:25 +08:00
8d54ea3374 feat: workerize external generation 2026-06-05 17:29:08 +08:00
189 changed files with 13945 additions and 3605 deletions

View File

@@ -1,229 +1,151 @@
---
name: spacetimedb-cli
description: SpacetimeDB CLI reference for initializing projects, building modules, publishing databases, querying data, and managing servers
triggers:
- spacetime init
- spacetime build
- spacetime publish
- spacetime dev
- spacetime sql
- spacetime call
- spacetime logs
- spacetime server
- spacetime login
- spacetime generate
- how do I use the CLI
- CLI command
description: SpacetimeDB 2.5 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 the user needs help with the `spacetime` CLI tool - initializing projects, building modules, publishing databases, querying data, managing servers, or troubleshooting CLI issues.
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.
## Quick Reference
## Genarrative Rules
### Project Initialization & Development
- 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
# Initialize new project
spacetime init my-project --lang rust|csharp|typescript|cpp
spacetime init my-project --template <template-id>
# Build module
spacetime build # release build
spacetime build --debug # faster iteration, slower runtime
spacetime build
spacetime build --debug
# Dev mode (auto-rebuild, auto-publish, generates bindings)
spacetime dev
spacetime dev --client-lang typescript --module-bindings-path ./client/src/module_bindings
# Publish to an explicit server
spacetime publish my-database --server http://127.0.0.1:3101 --yes=migrate,break-clients
# Generate client bindings
# 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
```
### Publishing & Deployment
## Genarrative Local Workflow
```bash
# Publish to an explicit server
spacetime publish my-database --server http://127.0.0.1:3101 --yes
# Prefer project wrappers
npm run dev:spacetime
npm run dev:api-server
npm run spacetime:generate
# Publish to local server
spacetime publish my-database --server local --yes
# Query local database
spacetime sql my-db --server http://127.0.0.1:3101 "SELECT * FROM players"
# Clear database and republish
spacetime publish my-database --clear-database --yes
# Logs
spacetime logs my-db --server http://127.0.0.1:3101 -f
```
### Database Interaction
## Database Interaction
```bash
# SQL queries
spacetime sql my-database "SELECT * FROM users"
spacetime sql my-database --interactive # REPL mode
# 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
# Call reducers
spacetime call my-database my_reducer '{"arg1": "value", "arg2": 123}'
# Reducer/procedure calls. Arguments are positional JSON values.
spacetime call --server http://127.0.0.1:3101 my-db my_reducer '"value"' '123'
# Subscribe to changes
spacetime subscribe my-database "SELECT * FROM users" --num-updates 10
# 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...
# View logs
spacetime logs my-database -f # follow logs
spacetime logs my-database -n 100 # up to 100 log lines
# Describe schema
spacetime describe my-database --json
spacetime describe my-database table users --json
spacetime describe my-database reducer my_reducer --json
# Subscribe from CLI
spacetime subscribe my-db "SELECT * FROM users" --num-updates 10 --server http://127.0.0.1:3101
```
### Database Management
## Server & Auth
```bash
# List databases
spacetime list
# Delete database
spacetime delete my-database
# Rename database
spacetime rename <database-identity> --to new-name
```
### Server Management
```bash
# List configured servers
spacetime server list
# Add server
spacetime server add local --url http://localhost:3000 --default
spacetime server add myserver --url https://my-spacetime.example.com
spacetime server add genarrative-dev --url http://127.0.0.1:3101
spacetime server ping genarrative-dev
# Set default server
spacetime server set-default local
# Test connectivity
spacetime server ping local
# Start local instance
spacetime start
# Clear local data
spacetime server clear
```
### Authentication
```bash
# Login (opens browser)
spacetime login
# Login with token
spacetime login --token <token>
# Show login status
spacetime login show
# Logout
spacetime logout
```
## Default Servers
| Name | URL | Description |
|------|-----|-------------|
| `local` | `http://127.0.0.1:3000` | Local development server |
| `dev` | `http://127.0.0.1:3101` | Genarrative local development server |
## Common Workflows
### New Project Setup
## Version & Runtime Verification
```bash
# 1. Login
spacetime login
# CLI resolution can be misleading; compare all candidates when diagnosing.
type -a spacetime
spacetime --version
spacetime version list
# 2. Create project
spacetime init my-game --lang rust
cd my-game
# 3. Start dev mode (auto-rebuilds and publishes)
spacetime dev
# 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
```
### Local Development
## Flags
```bash
# Start local server (in separate terminal)
spacetime start
# Publish to local
spacetime publish my-db --server local --clear-database --yes
# Query local database
spacetime sql my-db --server local "SELECT * FROM players"
```
### Generate Client Bindings
```bash
# After building module
spacetime build
spacetime generate --lang typescript --out-dir ./client/src/bindings --module-path .
# Or use dev mode which auto-generates
spacetime dev --client-lang typescript --module-bindings-path ./client/src/bindings
```
## Common Flags
| Flag | Short | Description |
|------|-------|-------------|
| `--server` | `-s` | Target server (nickname, hostname, or URL) |
| `--yes` | `-y` | Non-interactive mode (skip confirmations) |
| `--anonymous` | | Use anonymous identity |
| `--module-path` | `-p` | Path to module project |
| Flag | Description |
|------|-------------|
| `--server`, `-s` | Target server nickname, host, or URL |
| `--yes`, `-y` | Non-interactive prompt skipping; in 2.5 prefer 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"
### Not Logged In
```bash
spacetime login
# Or use --anonymous for public operations
```
### "Server not responding"
### Server Not Responding
```bash
spacetime server ping <server>
# For local: ensure spacetime start is running
curl -fsS http://127.0.0.1:3101/v1/ping
```
### "Schema conflict"
For local Genarrative work, start SpacetimeDB first with `npm run dev:spacetime`, then start `npm run dev:api-server`.
### Schema Conflict
```bash
# Clear data and republish
spacetime publish my-db --clear-database --yes
# Clear data and republish only when conflict
spacetime publish my-db --clear-database=on-conflict --yes
spacetime publish my-db --server http://127.0.0.1:3101 --delete-data=on-conflict --yes=migrate
```
### "Build failed"
Use `--delete-data=always` only with explicit approval.
### Version Mismatch
```bash
# Check Rust/C# toolchain
rustup show
# For Rust modules, ensure wasm32-unknown-unknown target
rustup target add wasm32-unknown-unknown
rg -n 'spacetimedb' server-rs/Cargo.toml
spacetime --version
spacetime version list
pid="$(systemctl show spacetimedb.service -p MainPID --value)"
"/proc/${pid}/exe" --version
```
## Module Languages
**Server-side (modules):** Rust, C#, TypeScript, C++
**Client SDKs:** TypeScript, C#, Rust, Python, Unreal Engine
**CLI `generate` targets:** TypeScript, C#, Rust, Unreal C++
## Notes
- Many commands are marked UNSTABLE and may change
- Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on the CLI default
- Use `--yes` flag in scripts to avoid interactive prompts
- Dev mode watches files and auto-rebuilds on changes
- Procedure calls are stable in 2.5; module HTTP handlers/webhooks, unstable view features, and RLS remain behind unstable gates per release notes.
- 2.5 fixes `publish --delete-data` config fallback so `spacetime.json` can provide the database name.
- Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on CLI defaults.

View File

@@ -1,345 +1,105 @@
---
name: spacetimedb-concepts
description: Understand SpacetimeDB architecture and core concepts. Use when learning SpacetimeDB or making architectural decisions.
license: Apache-2.0
metadata:
author: clockworklabs
version: "2.0"
description: Understand SpacetimeDB 2.5 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 is also a server. It lets you upload application logic directly into the database via WebAssembly modules, eliminating the traditional web/game server layer entirely.
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
## Critical Rules (Read First)
- 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.
These five rules prevent the most common SpacetimeDB mistakes:
## Critical Rules
1. **Reducers are transactional** they do not return data to callers. Use subscriptions to read data.
2. **Reducers must be deterministic** no filesystem, network, timers, or random. All state must come from tables.
3. **Read data via tables/subscriptions** — not reducer return values. Clients get data through subscribed queries.
4. **Auto-increment IDs are not sequential** — gaps are normal, do not use for ordering. Use timestamps or explicit sequence columns.
5. **`ctx.sender()` is the authenticated principal** — never trust identity passed as arguments. Always use `ctx.sender()` for authorization.
---
## Feature Implementation Checklist
When implementing a feature that spans backend and client:
1. **Backend:** Define table(s) to store the data
2. **Backend:** Define reducer(s) to mutate the data
3. **Client:** Subscribe to the table(s)
4. **Client:** Call the reducer(s) from UI — **do not skip this step**
5. **Client:** Render the data from the table(s)
**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them.
---
## Debugging Checklist
When things are not working:
1. Is SpacetimeDB server running? (`spacetime start`)
2. Is the module published? (`spacetime publish`)
3. Are client bindings generated? (`spacetime generate`)
4. Check server logs for errors (`spacetime logs <db-name>`)
5. **Is the reducer actually being called from the client?**
---
## CLI Commands
```bash
spacetime start
spacetime publish <db-name> --module-path <module-path>
spacetime publish <db-name> --clear-database -y --module-path <module-path>
spacetime generate --lang <lang> --out-dir <out> --module-path <module-path>
spacetime logs <db-name>
```
---
## What SpacetimeDB Is
SpacetimeDB combines a database and application server into a single deployable unit. Clients connect directly to the database and execute application logic inside it. The system is optimized for real-time applications requiring maximum speed and minimum latency.
Key characteristics:
- **In-memory execution**: Application state is served from memory for very low-latency access
- **Persistent storage**: Data is automatically persisted to a write-ahead log (WAL) for durability
- **Real-time synchronization**: Changes are automatically pushed to subscribed clients
- **Single deployment**: No separate servers, containers, or infrastructure to manage
## The Five Zen Principles
1. **Everything is a Table**: Your entire application state lives in tables. No separate cache layer, no Redis, no in-memory state to synchronize.
2. **Everything is Persistent**: SpacetimeDB persists state by default (for example via WAL-backed durability).
3. **Everything is Real-Time**: Clients are replicas of server state. Subscribe to data and it flows automatically.
4. **Everything is Transactional**: Every reducer runs atomically. Either all changes succeed or all roll back.
5. **Everything is Programmable**: Modules are real code (Rust, C#, TypeScript) running inside the database.
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.5**: 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
Tables store all data in SpacetimeDB. They use the relational model and support SQL queries for subscriptions.
### Defining Tables
Tables are defined using language-specific attributes. In 2.0, use `accessor` (not `name`) for the API name:
**Rust:**
```rust
#[spacetimedb::table(accessor = player, public)]
pub struct Player {
#[primary_key]
#[auto_inc]
id: u32,
#[index(btree)]
name: String,
#[unique]
email: String,
}
```
**C#:**
```csharp
[SpacetimeDB.Table(Accessor = "Player", Public = true)]
public partial struct Player
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public uint Id;
[SpacetimeDB.Index.BTree]
public string Name;
[SpacetimeDB.Unique]
public string Email;
}
```
**TypeScript:**
```typescript
const players = table(
{ name: 'players', public: true },
{
id: t.u32().primaryKey().autoInc(),
name: t.string().index('btree'),
email: t.string().unique(),
}
);
```
### Table Visibility
- **Private tables** (default): Only accessible by reducers and the database owner
- **Public tables**: Exposed for client read access through subscriptions. Writes still require reducers.
### Table Design Principles
Organize data by access pattern, not by entity:
**Decomposed approach (recommended):**
```
Player PlayerState PlayerStats
id <-- player_id player_id
name position_x total_kills
position_y total_deaths
velocity_x play_time
```
Benefits: reduced bandwidth, cache efficiency, schema evolution, semantic clarity.
- 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 transactional functions that modify database state. They are the primary client-invoked mutation path; procedures can also mutate tables by running explicit transactions.
Reducers are deterministic transactional functions. They are the primary client-invoked mutation path.
### Key Properties
- 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.
- **Transactional**: Run in isolated database transactions
- **Atomic**: Either all changes succeed or all roll back
- **Isolated**: Cannot interact with the outside world (no network, no filesystem)
- **Callable**: Clients invoke reducers as remote procedure calls
## Procedures
### Critical Reducer Rules
Procedures are stable in 2.5. They can be scheduled, can open explicit transactions with `with_tx` / `try_with_tx`, and can use outgoing HTTP (`ctx.http`).
1. **No global state**: Relying on static variables is undefined behavior
2. **No side effects**: Reducers cannot make network requests or access files
3. **Store state in tables**: All persistent state must be in tables
4. **No return data**: Reducers do not return data to callers — use subscriptions
5. **Must be deterministic**: No random, no timers, no external I/O
Genarrative default: keep external provider protocols in `platform-*` and orchestration in `api-server` unless a task explicitly moves a workflow into a module procedure.
### Defining Reducers
Module HTTP handlers/webhooks, unstable view features, and RLS `client_visibility_filter` remain gated behind unstable according to the 2.5 release notes.
**Rust:**
```rust
#[spacetimedb::reducer]
pub fn create_user(ctx: &ReducerContext, name: String, email: String) -> Result<(), String> {
if name.is_empty() {
return Err("Name cannot be empty".to_string());
}
ctx.db.user().insert(User { id: 0, name, email });
Ok(())
}
```
## Views
**C#:**
```csharp
[SpacetimeDB.Reducer]
public static void CreateUser(ReducerContext ctx, string name, string email)
{
if (string.IsNullOrEmpty(name))
throw new ArgumentException("Name cannot be empty");
ctx.Db.User.Insert(new User { Id = 0, Name = name, Email = email });
}
```
Views expose computed read-only data. In 2.4.1 Rust and TypeScript gained primary key support for procedural views; in 2.5 C# gained the same. Clients can receive `OnUpdate` 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.
### ReducerContext
## Event Tables
Every reducer receives a `ReducerContext` providing:
- **Database**: `ctx.db` (Rust field, TS property) / `ctx.Db` (C# property)
- **Sender**: `ctx.sender()` (Rust method) / `ctx.Sender` (C# property) / `ctx.sender` (TS property)
- **Connection ID**: `ctx.connection_id()` (Rust method) / `ctx.ConnectionId` (C# property) / `ctx.connectionId` (TS property)
- **Timestamp**: `ctx.timestamp` (Rust field, TS property) / `ctx.Timestamp` (C# property)
Event tables broadcast reducer/procedure-specific facts to subscribers and must be subscribed explicitly. They are excluded from `subscribe_to_all_tables()`.
## Event Tables (2.0)
2.5 adds broader layout-altering automigrations for event tables, including column removal, reordering, and type changes that regular tables reject. This relaxed migration behavior is for event-only tables, not persistent tables.
Event tables are the preferred way to broadcast reducer-specific data to clients.
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.
```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 });
}
```
Clients subscribe to event tables and use `on_insert` callbacks. Event tables must be subscribed explicitly and are excluded from `subscribe_to_all_tables()`.
Official 2.4.1/2.5 release notes document primary-key-backed update callbacks for procedural views, not event tables.
## Subscriptions
Subscriptions replicate database rows to clients in real-time.
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.
### How Subscriptions Work
Best practices:
1. **Subscribe**: Register SQL queries describing needed data
2. **Receive initial data**: All matching rows are sent immediately
3. **Receive updates**: Real-time updates when subscribed rows change
4. **React to changes**: Use callbacks (`onInsert`, `onDelete`, `onUpdate`)
- 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.
### Subscription Best Practices
## 2.2.0 to 2.5.0 Delta
1. **Group subscriptions by lifetime**: Keep always-needed data separate from temporary subscriptions
2. **Subscribe before unsubscribing**: When updating subscriptions, subscribe to new data first
3. **Avoid overlapping queries**: Distinct queries returning overlapping data cause redundant processing
4. **Use indexes**: Queries on indexed columns are efficient; full table scans are expensive
Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then:
## Modules
- **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.
Modules are WebAssembly bundles containing application logic that runs inside the database.
## Debugging Checklist
### Module Components
- **Tables**: Define the data schema
- **Reducers**: Define callable functions that modify state
- **Views**: Define read-only computed queries
- **Event Tables**: Broadcast reducer-specific data to clients (2.0)
- **Procedures**: (Beta) Functions that can have side effects (HTTP requests)
### Module Languages
Server-side modules can be written in: Rust, C#, TypeScript (beta)
### Module Lifecycle
1. **Write**: Define tables and reducers in your chosen language
2. **Compile**: Build to WebAssembly using the SpacetimeDB CLI
3. **Publish**: Upload to a SpacetimeDB host with `spacetime publish`
4. **Hot-swap**: Republish to update code without disconnecting clients
## Identity
Identity is SpacetimeDB's authentication system based on OpenID Connect (OIDC).
- **Identity**: A long-lived, globally unique identifier for a user.
- **ConnectionId**: Identifies a specific client connection.
```rust
#[spacetimedb::reducer]
pub fn do_something(ctx: &ReducerContext) {
let caller_identity = ctx.sender(); // Who is calling?
// NEVER trust identity passed as a reducer argument
}
```
### Authentication Providers
SpacetimeDB works with many OIDC providers, including SpacetimeAuth (built-in), Auth0, Clerk, Keycloak, Google, and GitHub.
## When to Use SpacetimeDB
### Ideal Use Cases
- **Real-time games**: MMOs, multiplayer games, turn-based games
- **Collaborative applications**: Document editing, whiteboards, design tools
- **Chat and messaging**: Real-time communication with presence
- **Live dashboards**: Streaming analytics and monitoring
### Key Decision Factors
Choose SpacetimeDB when you need:
- Sub-10ms latency for reads and writes
- Automatic real-time synchronization
- Transactional guarantees for all operations
- Simplified architecture (no separate cache, queue, or server)
### Less Suitable For
- **Batch analytics**: Optimized for OLTP, not OLAP
- **Large blob storage**: Better suited for structured relational data
- **Stateless APIs**: Traditional REST APIs do not need real-time sync
## Common Patterns
**Authentication check in reducer:**
```rust
#[spacetimedb::reducer]
fn admin_action(ctx: &ReducerContext) -> Result<(), String> {
let admin = ctx.db.admin().identity().find(&ctx.sender())
.ok_or("Not an admin")?;
Ok(())
}
```
**Scheduled reducer:**
```rust
#[spacetimedb::table(accessor = reminder, scheduled(send_reminder))]
pub struct Reminder {
#[primary_key]
#[auto_inc]
id: u64,
scheduled_at: ScheduleAt,
message: String,
}
#[spacetimedb::reducer]
fn send_reminder(ctx: &ReducerContext, reminder: Reminder) {
log::info!("Reminder: {}", reminder.message);
}
```
---
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
When modifying SpacetimeDB code:
- Make the smallest change necessary
- Do NOT touch unrelated files, configs, or dependencies
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
- 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`.

View File

@@ -1,646 +0,0 @@
---
name: spacetimedb-csharp
description: Build C# modules and clients for SpacetimeDB. Covers server-side module development and client SDK integration.
license: Apache-2.0
metadata:
author: clockworklabs
version: "2.0"
tested_with: "SpacetimeDB 2.0, .NET 8 SDK"
---
# SpacetimeDB C# SDK
This skill provides guidance for building C# server-side modules and C# clients that connect to SpacetimeDB 2.0.
---
## HALLUCINATED APIs — DO NOT USE
**These APIs DO NOT EXIST. LLMs frequently hallucinate them.**
```csharp
// WRONG — these table access patterns do not exist
ctx.db.tableName // Wrong casing — use ctx.Db
ctx.Db.tableName // Wrong casing — accessor must match exactly
ctx.Db.TableName.Get(id) // Use Find, not Get
ctx.Db.TableName.FindById(id) // Use index accessor: ctx.Db.TableName.Id.Find(id)
ctx.Db.table.field_name.Find(x) // Wrong! Use PascalCase: ctx.Db.Table.FieldName.Find(x)
Optional<string> field; // Use C# nullable: string? field
// WRONG — missing partial keyword
public struct MyTable { } // Must be "partial struct"
public class Module { } // Must be "static partial class"
// WRONG — non-partial types
[SpacetimeDB.Table(Accessor = "Player")]
public struct Player { } // WRONG — missing partial!
// WRONG — sum type syntax (VERY COMMON MISTAKE)
public partial struct Shape : TaggedEnum<(Circle, Rectangle)> { } // WRONG: struct, missing names
public partial record Shape : TaggedEnum<(Circle, Rectangle)> { } // WRONG: missing variant names
public partial class Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { } // WRONG: class
// WRONG — Index attribute without full qualification
[Index.BTree(Accessor = "idx", Columns = new[] { "Col" })] // Ambiguous with System.Index!
[SpacetimeDB.Index.BTree(Accessor = "idx", Columns = ["Col"])] // Valid with modern C# collection expressions
// WRONG — old 1.0 patterns
[SpacetimeDB.Table(Name = "Player")] // Use Accessor, not Name (2.0)
<PackageReference Include="SpacetimeDB.ServerSdk" /> // Use SpacetimeDB.Runtime
.WithModuleName("my-db") // Use .WithDatabaseName() (2.0)
ScheduleAt.Time(futureTime) // Use new ScheduleAt.Time(futureTime)
// WRONG — lifecycle hooks starting with "On"
[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]
public static void OnClientConnected(ReducerContext ctx) { } // STDB0010 error!
// WRONG — non-deterministic code in reducers
var random = new Random(); // Use ctx.Rng
var guid = Guid.NewGuid(); // Not allowed
var now = DateTime.Now; // Use ctx.Timestamp
// WRONG — collection parameters
int[] itemIds = { 1, 2, 3 };
_conn.Reducers.ProcessItems(itemIds); // Generated code expects List<T>!
```
### CORRECT PATTERNS
```csharp
using SpacetimeDB;
// CORRECT TABLE — must be partial struct, use Accessor
[SpacetimeDB.Table(Accessor = "Player", Public = true)]
public partial struct Player
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
[SpacetimeDB.Index.BTree]
public Identity OwnerId;
public string Name;
}
// CORRECT MODULE — must be static partial class
public static partial class Module
{
[SpacetimeDB.Reducer]
public static void CreatePlayer(ReducerContext ctx, string name)
{
ctx.Db.Player.Insert(new Player { Id = 0, OwnerId = ctx.Sender, Name = name });
}
}
// CORRECT DATABASE ACCESS — PascalCase, index-based lookups
var player = ctx.Db.Player.Id.Find(playerId); // Unique/PK: returns nullable
foreach (var p in ctx.Db.Player.OwnerId.Filter(ctx.Sender)) { } // BTree: returns IEnumerable
// CORRECT SUM TYPE — partial record with named tuple elements
[SpacetimeDB.Type]
public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { }
// CORRECT — collection parameters use List<T>
_conn.Reducers.ProcessItems(new List<int> { 1, 2, 3 });
```
---
## Common Mistakes Table
| Wrong | Right | Error |
|-------|-------|-------|
| Wrong .csproj name | `StdbModule.csproj` | Publish fails silently |
| .NET 9 SDK | .NET 8 SDK only | WASI compilation fails |
| Missing WASI workload | `dotnet workload install wasi-experimental` | Build fails |
| async/await in reducers | Synchronous only | Not supported |
| `table.Name.Update(...)` | `table.Id.Update(...)` | Update only via primary key (2.0) |
| Not calling `FrameTick()` | `conn.FrameTick()` in Update loop | No callbacks fire |
| Accessing `conn.Db` from background thread | Copy data in callback | Data races |
---
## Hard Requirements
1. **Tables and Module MUST be `partial`** — required for code generation
2. **Use `Accessor =` in table attributes**`Name =` is only for SQL compatibility (2.0)
3. **Project file MUST be named `StdbModule.csproj`** — CLI requirement
4. **Requires .NET 8 SDK** — .NET 9 and newer not yet supported
5. **Install WASI workload**`dotnet workload install wasi-experimental`
6. **Procedures are supported** — use `[SpacetimeDB.Procedure]` with `ProcedureContext` when needed
7. **Reducers must be deterministic** — no filesystem, network, timers, or `Random`
8. **Add `Public = true`** — if clients need to subscribe to a table
9. **Use `T?` for nullable fields** — not `Optional<T>`
10. **Pass `0` for auto-increment** — to trigger ID generation on insert
11. **Sum types must be `partial record`** — not struct or class
12. **Fully qualify Index attribute**`[SpacetimeDB.Index.BTree]` to avoid System.Index ambiguity
13. **Update only via primary key** — use delete+insert for non-PK changes (2.0)
14. **Use `SpacetimeDB.Runtime` package** — not `ServerSdk` (2.0)
15. **Use `List<T>` for collection parameters** — not arrays
16. **`Identity` is in `SpacetimeDB` namespace** — not `SpacetimeDB.Types`
---
## Server-Side Module Development
### Table Definition
```csharp
using SpacetimeDB;
[SpacetimeDB.Table(Accessor = "Player", Public = true)]
public partial struct Player
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
[SpacetimeDB.Index.BTree]
public Identity OwnerId;
public string Name;
public Timestamp CreatedAt;
}
// Multi-column index (use fully-qualified attribute!)
[SpacetimeDB.Table(Accessor = "Score", Public = true)]
[SpacetimeDB.Index.BTree(Accessor = "by_player_game", Columns = new[] { "PlayerId", "GameId" })]
public partial struct Score
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
public Identity PlayerId;
public string GameId;
public int Points;
}
```
### Field Attributes
```csharp
[SpacetimeDB.PrimaryKey] // Exactly one per table (required)
[SpacetimeDB.AutoInc] // Auto-increment (integer fields only)
[SpacetimeDB.Unique] // Unique constraint
[SpacetimeDB.Index.BTree] // Single-column B-tree index
[SpacetimeDB.Default(value)] // Default value for new columns
```
### SpacetimeDB Column Types
```csharp
Identity // User identity (SpacetimeDB namespace, not SpacetimeDB.Types)
Timestamp // Timestamp (use ctx.Timestamp server-side, never DateTime.Now)
ScheduleAt // For scheduled tables
T? // Nullable (e.g., string?)
List<T> // Collections (use List, not arrays)
```
Standard C# primitives (`bool`, `byte`..`ulong`, `float`, `double`, `string`) are all supported.
### Insert with Auto-Increment
```csharp
var player = ctx.Db.Player.Insert(new Player
{
Id = 0, // Pass 0 to trigger auto-increment
OwnerId = ctx.Sender,
Name = name,
CreatedAt = ctx.Timestamp
});
ulong newId = player.Id; // Insert returns the row with generated ID
```
### Module and Reducers
```csharp
using SpacetimeDB;
public static partial class Module
{
[SpacetimeDB.Reducer]
public static void CreateTask(ReducerContext ctx, string title)
{
if (string.IsNullOrEmpty(title))
throw new Exception("Title cannot be empty");
ctx.Db.Task.Insert(new Task
{
Id = 0,
OwnerId = ctx.Sender,
Title = title,
Completed = false
});
}
[SpacetimeDB.Reducer]
public static void CompleteTask(ReducerContext ctx, ulong taskId)
{
if (ctx.Db.Task.Id.Find(taskId) is not Task task)
throw new Exception("Task not found");
if (task.OwnerId != ctx.Sender)
throw new Exception("Not authorized");
ctx.Db.Task.Id.Update(task with { Completed = true });
}
[SpacetimeDB.Reducer]
public static void DeleteTask(ReducerContext ctx, ulong taskId)
{
ctx.Db.Task.Id.Delete(taskId);
}
}
```
### Lifecycle Reducers
```csharp
public static partial class Module
{
[SpacetimeDB.Reducer(ReducerKind.Init)]
public static void Init(ReducerContext ctx)
{
Log.Info("Module initialized");
}
// CRITICAL: no "On" prefix!
[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]
public static void ClientConnected(ReducerContext ctx)
{
Log.Info($"Client connected: {ctx.Sender}");
if (ctx.Db.User.Identity.Find(ctx.Sender) is User user)
{
ctx.Db.User.Identity.Update(user with { Online = true });
}
else
{
ctx.Db.User.Insert(new User { Identity = ctx.Sender, Online = true });
}
}
[SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)]
public static void ClientDisconnected(ReducerContext ctx)
{
if (ctx.Db.User.Identity.Find(ctx.Sender) is User user)
{
ctx.Db.User.Identity.Update(user with { Online = false });
}
}
}
```
### Event Tables (2.0)
Reducer callbacks are removed in 2.0. Use event tables + `OnInsert` instead.
```csharp
[SpacetimeDB.Table(Accessor = "DamageEvent", Public = true, Event = true)]
public partial struct DamageEvent
{
public Identity Target;
public uint Amount;
}
[SpacetimeDB.Reducer]
public static void DealDamage(ReducerContext ctx, Identity target, uint amount)
{
ctx.Db.DamageEvent.Insert(new DamageEvent { Target = target, Amount = amount });
}
```
Client subscribes and uses `OnInsert`:
```csharp
conn.Db.DamageEvent.OnInsert += (ctx, evt) => {
PlayDamageAnimation(evt.Target, evt.Amount);
};
```
Event tables must be subscribed explicitly — they are excluded from `SubscribeToAllTables()`.
### Database Access
```csharp
// Find by primary key — returns nullable, use pattern matching
if (ctx.Db.Task.Id.Find(taskId) is Task task) { /* use task */ }
// Update by primary key (2.0: only primary key has .Update)
ctx.Db.Task.Id.Update(task with { Title = newTitle });
// Delete by primary key
ctx.Db.Task.Id.Delete(taskId);
// Find by unique index — returns nullable
if (ctx.Db.Player.Username.Find("alice") is Player player) { }
// Filter by B-tree index — returns iterator
foreach (var task in ctx.Db.Task.OwnerId.Filter(ctx.Sender)) { }
// Full table scan — avoid for large tables
foreach (var task in ctx.Db.Task.Iter()) { }
var count = ctx.Db.Task.Count;
```
### Custom Types and Sum Types
```csharp
[SpacetimeDB.Type]
public partial struct Position { public int X; public int Y; }
// Sum types MUST be partial record with named tuple
[SpacetimeDB.Type]
public partial struct Circle { public int Radius; }
[SpacetimeDB.Type]
public partial struct Rectangle { public int Width; public int Height; }
[SpacetimeDB.Type]
public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { }
// Creating sum type values
var circle = new Shape.Circle(new Circle { Radius = 10 });
```
### Scheduled Tables
```csharp
[SpacetimeDB.Table(Accessor = "Reminder", Scheduled = nameof(Module.SendReminder))]
public partial struct Reminder
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
public string Message;
public ScheduleAt ScheduledAt;
}
public static partial class Module
{
[SpacetimeDB.Reducer]
public static void SendReminder(ReducerContext ctx, Reminder reminder)
{
Log.Info($"Reminder: {reminder.Message}");
}
[SpacetimeDB.Reducer]
public static void CreateReminder(ReducerContext ctx, string message, ulong delaySecs)
{
ctx.Db.Reminder.Insert(new Reminder
{
Id = 0,
Message = message,
ScheduledAt = new ScheduleAt.Time(ctx.Timestamp + TimeSpan.FromSeconds(delaySecs))
});
}
}
```
### Logging
```csharp
Log.Debug("Debug message");
Log.Info("Information");
Log.Warn("Warning");
Log.Error("Error occurred");
Log.Exception("Critical failure"); // Logs at error level
```
### ReducerContext API
```csharp
ctx.Sender // Identity of the caller
ctx.Timestamp // Current timestamp
ctx.Db // Database access
ctx.Identity // Module's own identity
ctx.ConnectionId // Connection ID (nullable)
ctx.SenderAuth // Authorization context (JWT claims, internal call detection)
ctx.Rng // Deterministic random number generator
```
### Error Handling
Throwing an exception in a reducer rolls back the entire transaction:
```csharp
[SpacetimeDB.Reducer]
public static void TransferCredits(ReducerContext ctx, Identity toUser, uint amount)
{
if (ctx.Db.User.Identity.Find(ctx.Sender) is not User sender)
throw new Exception("Sender not found");
if (sender.Credits < amount)
throw new Exception("Insufficient credits");
ctx.Db.User.Identity.Update(sender with { Credits = sender.Credits - amount });
if (ctx.Db.User.Identity.Find(toUser) is User receiver)
ctx.Db.User.Identity.Update(receiver with { Credits = receiver.Credits + amount });
}
```
---
## Project Setup
### Required .csproj (MUST be named `StdbModule.csproj`)
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>wasi-wasm</RuntimeIdentifier>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SpacetimeDB.Runtime" Version="1.*" />
</ItemGroup>
</Project>
```
### Prerequisites
```bash
# Install .NET 8 SDK (required, not .NET 9)
# Install WASI workload
dotnet workload install wasi-experimental
```
---
## Client SDK
### Installation
```bash
dotnet add package SpacetimeDB.ClientSDK
```
### Generate Module Bindings
```bash
spacetime generate --lang csharp --out-dir module_bindings --module-path PATH_TO_MODULE
```
This creates `SpacetimeDBClient.g.cs`, `Tables/*.g.cs`, `Reducers/*.g.cs`, and `Types/*.g.cs`.
### Connection Setup
```csharp
using SpacetimeDB;
using SpacetimeDB.Types;
var conn = DbConnection.Builder()
.WithUri("http://localhost:3000")
.WithDatabaseName("my-database")
.WithToken(savedToken)
.OnConnect(OnConnected)
.OnConnectError(err => Console.Error.WriteLine($"Failed: {err}"))
.OnDisconnect((conn, err) => { if (err != null) Console.Error.WriteLine(err); })
.Build();
void OnConnected(DbConnection conn, Identity identity, string authToken)
{
// Save authToken to persistent storage for reconnection
Console.WriteLine($"Connected: {identity}");
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.SubscribeToAllTables();
}
```
### Critical: FrameTick
**The SDK does NOT automatically process messages.** You must call `FrameTick()` regularly.
```csharp
// Console application
while (running) { conn.FrameTick(); Thread.Sleep(16); }
// Unity: call conn?.FrameTick() in Update()
```
**Warning**: Do NOT call `FrameTick()` from a background thread. It modifies `conn.Db` and can cause data races.
### Subscribing to Tables
```csharp
// SQL queries
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.OnError((ctx, err) => Console.Error.WriteLine($"Subscription failed: {err}"))
.Subscribe(new[] {
"SELECT * FROM player",
"SELECT * FROM message WHERE sender = :sender"
});
// Subscribe to all tables (development only)
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.SubscribeToAllTables();
// Subscription handle for later unsubscribe
SubscriptionHandle handle = conn.SubscriptionBuilder()
.OnApplied(ctx => Console.WriteLine("Applied"))
.Subscribe(new[] { "SELECT * FROM player" });
handle.UnsubscribeThen(ctx => Console.WriteLine("Unsubscribed"));
```
**Warning**: `SubscribeToAllTables()` cannot be mixed with `Subscribe()` on the same connection.
### Accessing the Client Cache
```csharp
// Iterate all rows
foreach (var player in ctx.Db.Player.Iter()) { Console.WriteLine(player.Name); }
// Count rows
int playerCount = ctx.Db.Player.Count;
// Find by unique/primary key — returns nullable
Player? player = ctx.Db.Player.Identity.Find(someIdentity);
if (player != null) { Console.WriteLine(player.Name); }
// Filter by BTree index — returns IEnumerable
foreach (var p in ctx.Db.Player.Level.Filter(1)) { }
```
### Row Event Callbacks
```csharp
ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => {
Console.WriteLine($"Player joined: {player.Name}");
};
ctx.Db.Player.OnDelete += (EventContext ctx, Player player) => {
Console.WriteLine($"Player left: {player.Name}");
};
ctx.Db.Player.OnUpdate += (EventContext ctx, Player oldRow, Player newRow) => {
Console.WriteLine($"Player {oldRow.Name} renamed to {newRow.Name}");
};
// Checking event source
ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => {
switch (ctx.Event)
{
case Event<Reducer>.SubscribeApplied:
break; // Initial subscription data
case Event<Reducer>.Reducer(var reducerEvent):
Console.WriteLine($"Reducer: {reducerEvent.Reducer}");
break;
}
};
```
### Calling Reducers
```csharp
ctx.Reducers.SendMessage("Hello, world!");
ctx.Reducers.CreatePlayer("NewPlayer");
// Reducer completion callbacks
conn.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => {
if (ctx.Event.Status is Status.Committed)
Console.WriteLine($"Message sent: {text}");
else if (ctx.Event.Status is Status.Failed(var reason))
Console.Error.WriteLine($"Send failed: {reason}");
};
// Unhandled reducer errors
conn.OnUnhandledReducerError += (ReducerEventContext ctx, Exception ex) => {
Console.Error.WriteLine($"Reducer error: {ex.Message}");
};
```
### Identity and Authentication
```csharp
// In OnConnect callback — save token for reconnection
void OnConnected(DbConnection conn, Identity identity, string authToken)
{
// Save authToken to persistent storage (file, config, PlayerPrefs, etc.)
SaveToken(authToken);
}
// Reconnect with saved token
string savedToken = LoadToken();
DbConnection.Builder()
.WithUri("http://localhost:3000")
.WithDatabaseName("my-database")
.WithToken(savedToken)
.OnConnect(OnConnected)
.Build();
// Pass null or omit WithToken for anonymous connection
```
---
## Commands
```bash
spacetime start
spacetime publish <module-name> --module-path <backend-dir>
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
spacetime generate --lang csharp --out-dir <client>/SpacetimeDB --module-path <backend-dir>
spacetime logs <module-name>
```

View File

@@ -1,312 +1,170 @@
---
name: spacetimedb-rust
description: Develop SpacetimeDB server modules in Rust. Use when writing reducers, tables, or module logic.
license: Apache-2.0
metadata:
author: clockworklabs
version: "2.0"
description: Develop SpacetimeDB 2.5 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
SpacetimeDB modules are WebAssembly applications that run inside the database. They define tables to store data and reducers to modify data. Clients connect directly to the database and execute application logic inside it.
Use this skill for Rust code in `server-rs/crates/spacetime-module` and related Genarrative schema/migration work.
> **Tested with:** SpacetimeDB 2.0+ APIs
## 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
**These APIs/patterns are incorrect. LLMs frequently hallucinate them.**
Both macro forms are valid in 2.0: `#[spacetimedb::table(...)]` / `#[table(...)]` and `#[spacetimedb::reducer]` / `#[reducer]`.
## Hallucinated APIs: Do Not Use
```rust
#[derive(Table)] // Tables use #[table] attribute, not derive
#[derive(Reducer)] // Reducers use #[reducer] attribute
#[derive(Table)] // Tables use #[table], not derive
#[derive(Reducer)] // Reducers use #[reducer], not derive
#[derive(SpacetimeType)] // Do not derive this on #[table] structs
// WRONG — SpacetimeType on tables
#[derive(SpacetimeType)] // DO NOT use on #[table] structs!
#[table(accessor = my_table)]
pub struct MyTable { ... }
pub fn reducer(ctx: &mut ReducerContext) {} // Use &ReducerContext
// WRONG — mutable context
pub fn my_reducer(ctx: &mut ReducerContext, ...) { } // Should be &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
// WRONG — table access without parentheses
ctx.db.player // Should be ctx.db.player()
ctx.db.player.find(id) // Should be ctx.db.player().id().find(&id)
// WRONG — old 1.0 patterns
ctx.sender // Use ctx.sender() — method, not field (2.0)
.with_module_name("db") // Use .with_database_name() (2.0)
ctx.db.user().name().update(..) // Update only via primary key (2.0)
spacetimedb = { version = "...", features = ["unstable"] } // Not needed for procedures in 2.5
```
### CORRECT PATTERNS:
## Required Patterns
```rust
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
use spacetimedb::SpacetimeType; // Only for custom types, NOT tables
use spacetimedb::{reducer, table, Identity, ReducerContext, Table, Timestamp};
use spacetimedb::SpacetimeType; // Custom types only, not tables
// CORRECT TABLE — accessor, not name; no SpacetimeType derive!
#[table(accessor = player, public)]
pub struct Player {
#[primary_key]
pub id: u64,
pub name: String,
}
// CORRECT REDUCER — immutable context, sender() is a method
#[reducer]
pub fn create_player(ctx: &ReducerContext, name: String) {
ctx.db.player().insert(Player { id: 0, name });
}
// CORRECT TABLE ACCESS — methods with parentheses, sender() method
let player = ctx.db.player().id().find(&player_id);
let caller = ctx.sender();
```
### DO NOT:
- **Derive `SpacetimeType` on `#[table]` structs** — the macro handles this
- **Use mutable context** — `&ReducerContext`, not `&mut ReducerContext`
- **Forget `Table` trait import** — required for table operations
- **Use field access for tables** — `ctx.db.player()` not `ctx.db.player`
- **Use `ctx.sender`** — it's `ctx.sender()` (method) in 2.0
---
## Common Mistakes Table
| Wrong | Right | Error |
|-------|-------|-------|
| `#[table(accessor = "my_table")]` | `#[table(accessor = my_table)]` | String literals not allowed |
| Missing `public` on table | Add `public` flag | Clients can't subscribe |
| Network/filesystem in reducer | Use procedures instead | Sandbox violation |
| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed |
---
## Hard Requirements
1. **DO NOT derive `SpacetimeType` on `#[table]` structs** — the macro handles this
2. **Import `Table` trait** — required for all table operations
3. **Use `&ReducerContext`** — not `&mut ReducerContext`
4. **Tables are methods**`ctx.db.table()` not `ctx.db.table`
5. **Use `ctx.sender()`** — method call, not field access (2.0)
6. **Use `accessor =` for API handles**`name = "..."` is optional canonical naming in table/index attributes
7. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG
8. **Use `ctx.rng()`** — not `rand` crate for random numbers
9. **Add `public` flag** — if clients need to subscribe to a table
10. **Update only via primary key** — use delete+insert for non-PK changes (2.0)
---
## Project Setup
```toml
[package]
name = "my-module"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
spacetimedb = { workspace = true }
log = "0.4"
```
### Essential Imports
```rust
use spacetimedb::{ReducerContext, Table};
use spacetimedb::{Identity, Timestamp, ConnectionId, ScheduleAt};
```
## Table Definitions
```rust
#[spacetimedb::table(accessor = player, public)]
pub struct Player {
#[primary_key]
#[auto_inc]
id: u64,
name: String,
score: u32,
pub id: u64,
pub owner: Identity,
pub name: String,
pub created_at: Timestamp,
}
```
### Table Attributes
| Attribute | Description |
|-----------|-------------|
| `accessor = identifier` | Required. The API name used in `ctx.db.{accessor}()` |
| `public` | Makes table visible to clients via subscriptions |
| `scheduled(function_name)` | Creates a schedule table that triggers the named reducer or procedure |
| `index(accessor = idx, btree(columns = [a, b]))` | Multi-column index |
### Column Attributes
| Attribute | Description |
|-----------|-------------|
| `#[primary_key]` | Unique identifier for the row (one per table max) |
| `#[unique]` | Enforces uniqueness, enables `find()` method |
| `#[auto_inc]` | Auto-generates unique integer values when inserting 0 |
| `#[index(btree)]` | Creates a B-tree index for efficient lookups |
### Supported Column Types
**Primitives**: `u8`-`u256`, `i8`-`i256`, `f32`, `f64`, `bool`, `String`
**SpacetimeDB Types**: `Identity`, `ConnectionId`, `Timestamp`, `Uuid`, `ScheduleAt`
**Collections**: `Vec<T>`, `Option<T>`, `Result<T, E>`
**Custom Types**: Any struct/enum with `#[derive(SpacetimeType)]`
---
## Reducers
```rust
#[spacetimedb::reducer]
#[reducer]
pub fn create_player(ctx: &ReducerContext, name: String) -> Result<(), String> {
if name.is_empty() {
return Err("Name cannot be empty".to_string());
if name.trim().is_empty() {
return Err("name required".to_string());
}
ctx.db.player().insert(Player { id: 0, name, score: 0 });
ctx.db.player().try_insert(Player {
id: 0,
owner: ctx.sender(),
name,
created_at: ctx.timestamp,
})?;
Ok(())
}
```
### Reducer Rules
Hard requirements:
1. First parameter must be `&ReducerContext`
2. Return `()`, `Result<(), String>`, or `Result<(), E>` where `E: Display`
3. All changes roll back on panic or `Err` return
4. Must import `Table` trait: `use spacetimedb::Table;`
- 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.
### ReducerContext
## Tables
```rust
ctx.db // Database access
ctx.sender() // Identity of the caller (method, not field!)
ctx.connection_id() // Option<ConnectionId> (None for scheduled/system reducers)
ctx.timestamp // Invocation timestamp
ctx.identity() // Module's own identity
ctx.rng() // Deterministic RNG (method, not field!)
#[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
### Insert
```rust
// Insert returns the row with auto_inc values populated
let player = ctx.db.player().insert(Player { id: 0, name: "Alice".into(), score: 100 });
log::info!("Created player with id: {}", player.id);
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);
```
### Find and Filter
```rust
// Find by unique/primary key — returns Option
if let Some(player) = ctx.db.player().id().find(&123) {
log::info!("Found: {}", player.name);
}
// Optional clarity: typed literals can avoid inference ambiguity
if let Some(player) = ctx.db.player().id().find(&123u64) {
log::info!("Found: {}", player.name);
}
// Filter by indexed column — returns iterator
for player in ctx.db.player().name().filter(&"Alice".to_string()) {
log::info!("Player: {}", player.name);
}
// Full table scan
for player in ctx.db.player().iter() { }
let total = ctx.db.player().count();
```
### Update
```rust
// Update via primary key (2.0: only primary key has update)
if let Some(player) = ctx.db.player().id().find(&123) {
ctx.db.player().id().update(Player { score: player.score + 10, ..player });
}
// For non-PK changes: delete + insert
if let Some(old) = ctx.db.player().id().find(&id) {
ctx.db.player().id().delete(&id);
ctx.db.player().insert(Player { name: new_name, ..old });
}
```
### Delete
```rust
// Delete by primary key
ctx.db.player().id().delete(&123);
// Delete by indexed column (collect first to avoid iterator invalidation)
let to_remove: Vec<u64> = ctx.db.player().name().filter(&"Alice".to_string())
.map(|p| p.id)
.collect();
for id in to_remove {
ctx.db.player().id().delete(&id);
}
```
---
For delete/update based on non-PK filters, collect keys first to avoid iterator invalidation.
## Indexes
```rust
// Single-column index
#[spacetimedb::table(accessor = player, public)]
pub struct Player {
#[primary_key]
id: u64,
#[index(btree)]
level: u32,
name: String,
}
// Multi-column index
#[spacetimedb::table(
accessor = score, public,
accessor = score,
public,
index(accessor = by_player_level, btree(columns = [player_id, level]))
)]
pub struct Score {
player_id: u32,
level: u32,
points: i64,
pub player_id: u32,
pub level: u32,
pub points: i64,
}
// Multi-column index querying: prefix match (first column only)
for s in ctx.db.score().by_player_level().filter(&(42,)) {
log::info!("Player 42, any level: {} pts", s.points);
}
// Full match (both columns)
for s in ctx.db.score().by_player_level().filter(&(42, 5)) {
log::info!("Player 42, level 5: {} pts", s.points);
}
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 (2.0)
Reducer callbacks are removed in 2.0. Use event tables + `on_insert` instead.
## Event Tables
```rust
#[table(accessor = damage_event, public, event)]
@@ -321,182 +179,65 @@ fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) {
}
```
Client subscribes and uses `on_insert`:
Event tables must be subscribed explicitly and are excluded from `subscribe_to_all_tables()`.
In 2.5, 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/2.5 release notes tie primary-key-backed update callbacks to procedural views, not event tables.
## Views
```rust
conn.db.damage_event().on_insert(|ctx, event| {
play_damage_animation(event.target, event.amount);
});
#[spacetimedb::view(accessor = my_players, public, primary_key = id)]
pub fn my_players(ctx: &spacetimedb::ViewContext) -> Vec<Player> {
ctx.db.player().owner().filter(&ctx.sender()).collect()
}
```
Event tables must be subscribed explicitly — they are excluded from `subscribe_to_all_tables()`.
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 Reducers
## Lifecycle & Scheduled Reducers
```rust
#[spacetimedb::reducer(init)]
pub fn init(ctx: &ReducerContext) -> Result<(), String> {
log::info!("Database initializing...");
ctx.db.config().insert(Config {
id: 0,
max_players: 100,
game_mode: "default".to_string(),
});
Ok(())
}
pub fn init(ctx: &ReducerContext) -> Result<(), String> { Ok(()) }
#[spacetimedb::reducer(client_connected)]
pub fn on_connect(ctx: &ReducerContext) -> Result<(), String> {
let caller = ctx.sender();
log::info!("Client connected: {}", caller);
if let Some(user) = ctx.db.user().identity().find(&caller) {
ctx.db.user().identity().update(User { online: true, ..user });
} else {
ctx.db.user().insert(User {
identity: caller,
name: format!("User-{}", &caller.to_hex()[..8]),
online: true,
});
}
Ok(())
}
pub fn on_connect(ctx: &ReducerContext) -> Result<(), String> { Ok(()) }
#[spacetimedb::reducer(client_disconnected)]
pub fn on_disconnect(ctx: &ReducerContext) -> Result<(), String> {
let caller = ctx.sender();
if let Some(user) = ctx.db.user().identity().find(&caller) {
ctx.db.user().identity().update(User { online: false, ..user });
}
Ok(())
}
```
pub fn on_disconnect(ctx: &ReducerContext) -> Result<(), String> { Ok(()) }
---
use spacetimedb::{ScheduleAt, TimeDuration};
## Scheduled Reducers
```rust
use spacetimedb::ScheduleAt;
use std::time::Duration;
#[spacetimedb::table(accessor = game_tick_schedule, scheduled(game_tick))]
pub struct GameTickSchedule {
#[primary_key]
#[auto_inc]
scheduled_id: u64,
scheduled_at: ScheduleAt,
}
#[spacetimedb::reducer]
fn game_tick(ctx: &ReducerContext, schedule: GameTickSchedule) {
if !ctx.sender_auth().is_internal() { return; }
log::info!("Game tick at {:?}", ctx.timestamp);
}
// Schedule at interval (e.g., in init reducer)
ctx.db.game_tick_schedule().insert(GameTickSchedule {
scheduled_id: 0,
scheduled_at: ScheduleAt::Interval(Duration::from_millis(100).into()),
scheduled_at: ScheduleAt::Interval(std::time::Duration::from_millis(100).into()),
});
// Schedule at specific time
let run_at = ctx.timestamp + Duration::from_secs(delay_secs);
ctx.db.reminder_schedule().insert(ReminderSchedule {
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.
## Identity and Authentication
## Procedures
```rust
#[spacetimedb::table(accessor = user, public)]
pub struct User {
#[primary_key]
identity: Identity,
name: String,
online: bool,
}
#[spacetimedb::reducer]
pub fn set_name(ctx: &ReducerContext, new_name: String) -> Result<(), String> {
let caller = ctx.sender();
let user = ctx.db.user().identity().find(&caller)
.ok_or("User not found — connect first")?;
ctx.db.user().identity().update(User { name: new_name, ..user });
Ok(())
}
```
### Owner-Only Reducer Pattern
```rust
fn require_owner(ctx: &ReducerContext, entity_owner: &Identity) -> Result<(), String> {
if ctx.sender() != *entity_owner {
Err("Not authorized: you don't own this entity".to_string())
} else {
Ok(())
}
}
#[spacetimedb::reducer]
pub fn rename_character(ctx: &ReducerContext, char_id: u64, new_name: String) -> Result<(), String> {
let character = ctx.db.character().id().find(&char_id)
.ok_or("Character not found")?;
require_owner(ctx, &character.owner)?;
ctx.db.character().id().update(Character { name: new_name, ..character });
Ok(())
}
```
---
## Error Handling
```rust
// Sender error — return Err (user sees message, transaction rolls back cleanly)
#[spacetimedb::reducer]
pub fn transfer(ctx: &ReducerContext, to: Identity, amount: u64) -> Result<(), String> {
let sender = ctx.db.wallet().identity().find(&ctx.sender())
.ok_or("Wallet not found")?;
if sender.balance < amount {
return Err("Insufficient balance".to_string());
}
// ... proceed with transfer
Ok(())
}
// Programmer error — panic (destroys the WASM instance, expensive!)
// Only use for truly impossible states
#[spacetimedb::reducer]
pub fn process(ctx: &ReducerContext, id: u64) {
let item = ctx.db.item().id().find(&id)
.expect("BUG: item should exist at this point");
// ...
}
```
Prefer `Result<(), String>` for all expected failure cases. Panics destroy and recreate the WASM instance.
---
## Procedures (Beta)
> Procedures are behind the `unstable` feature in `spacetimedb`.
> In `Cargo.toml`: `spacetimedb = { version = "...", features = ["unstable"] }`
Procedures are stable in 2.5 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 data = fetch_from_url(&url)?;
let body = ctx.http.get(url).send()?.text()?;
ctx.try_with_tx(|tx| {
tx.db.external_data().insert(ExternalData { id: 0, content: data });
tx.db.external_data().insert(ExternalData { id: 0, content: body });
Ok(())
})?;
Ok(())
@@ -505,52 +246,35 @@ fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), Str
| Reducers | Procedures |
|----------|------------|
| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) |
| Direct `ctx.db` access | Must use `ctx.with_tx()` |
| No HTTP/network | HTTP allowed |
| No return values | Can return data |
| `&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.
## Custom Types
## Identity & Auth
```rust
use spacetimedb::SpacetimeType;
#[derive(SpacetimeType)]
pub enum PlayerStatus { Active, Idle, Away }
#[derive(SpacetimeType)]
pub struct Position { x: f32, y: f32, z: f32 }
// Use in table (DO NOT derive SpacetimeType on the table!)
#[spacetimedb::table(accessor = player, public)]
pub struct Player {
#[primary_key]
id: u64,
status: PlayerStatus,
position: Position,
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 --module-path .
spacetime publish my_database --clear-database --module-path .
spacetime logs my_database
spacetime call my_database create_player "Alice"
spacetime sql my_database "SELECT * FROM player"
spacetime generate --lang rust --out-dir <client>/src/module_bindings --module-path <backend-dir>
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
```
## Important Constraints
1. **No Global State**: Static/global variables are undefined behavior across reducer calls
2. **No Side Effects**: Reducers cannot make network requests or file I/O
3. **Deterministic Execution**: Use `ctx.rng()` and `ctx.new_uuid_*()` for randomness
4. **Transactional**: All reducer changes roll back on failure
5. **Isolated**: Reducers don't see concurrent changes until commit

View File

@@ -1,489 +0,0 @@
---
name: spacetimedb-typescript
description: Build TypeScript clients for SpacetimeDB. Use when connecting to SpacetimeDB from web apps, Node.js, Deno, Bun, or other JavaScript runtimes.
license: Apache-2.0
metadata:
author: clockworklabs
version: "2.0"
---
# SpacetimeDB TypeScript SDK
Build real-time TypeScript clients that connect directly to SpacetimeDB modules. The SDK provides type-safe database access, automatic synchronization, and reactive updates for web apps, Node.js, Deno, Bun, and other JavaScript runtimes.
---
## HALLUCINATED APIs — DO NOT USE
**These APIs DO NOT EXIST. LLMs frequently hallucinate them.**
```typescript
// WRONG PACKAGE — does not exist
import { SpacetimeDBClient } from "@clockworklabs/spacetimedb-sdk";
// WRONG — these methods don't exist
SpacetimeDBClient.connect(...);
SpacetimeDBClient.call("reducer_name", [...]);
connection.call("reducer_name", [arg1, arg2]);
// WRONG — positional reducer arguments
conn.reducers.doSomething("value"); // WRONG!
// WRONG — old 1.0 patterns
spacetimedb.reducer('reducer_name', params, fn); // Use export const name = spacetimedb.reducer(params, fn)
schema(myTable); // Use schema({ myTable })
schema(t1, t2, t3); // Use schema({ t1, t2, t3 })
scheduled: 'run_cleanup' // Use scheduled: () => run_cleanup
.withModuleName('db') // Use .withDatabaseName('db') (2.0)
setReducerFlags.x('NoSuccessNotify') // Removed in 2.0
```
### CORRECT PATTERNS:
```typescript
// CORRECT IMPORTS
import { DbConnection, tables } from './module_bindings'; // Generated!
import { SpacetimeDBProvider, useTable } from 'spacetimedb/react';
import { Identity } from 'spacetimedb';
// CORRECT REDUCER CALLS — object syntax, not positional!
conn.reducers.doSomething({ value: 'test' });
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
// CORRECT DATA ACCESS — useTable returns [rows, isReady]
const [items, isReady] = useTable(tables.item);
```
### DO NOT:
- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)`
- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings`
---
## Common Mistakes Table
### Server-side errors
| Wrong | Right | Error |
|-------|-------|-------|
| Missing `package.json` | Create `package.json` | "could not detect language" |
| Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" |
| Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle |
| `indexes` in COLUMNS (2nd arg) | `indexes` in OPTIONS (1st arg) of `table()` | "reading 'tag'" error |
| Index without `algorithm` | `algorithm: 'btree'` | "reading 'tag'" error |
| `filter({ ownerId })` | `filter(ownerId)` | "does not exist in type 'Range'" |
| `.filter()` on unique column | `.find()` on unique column | TypeError |
| `insert({ ...without id })` | `insert({ id: 0n, ... })` | "Property 'id' is missing" |
| `const id = table.insert(...)` | `const row = table.insert(...)` | `.insert()` returns ROW, not ID |
| `.unique()` + explicit index | Just use `.unique()` | "name is used for multiple entities" |
| Import spacetimedb from index.ts | Import from schema.ts | "Cannot access before initialization" |
| Incorrect multi-column `.filter()` range shape | Match index prefix/tuple shape | Empty results or range/type errors |
| `.iter()` in views | Use index lookups only | Views can't scan tables |
| `ctx.db` in procedures | `ctx.withTx(tx => tx.db...)` | Procedures need explicit transactions |
### Client-side errors
| Wrong | Right | Error |
|-------|-------|-------|
| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render |
| `const rows = useTable(table)` | `const [rows, isReady] = useTable(table)` | Tuple destructuring |
| Optimistic UI updates | Let subscriptions drive state | Desync issues |
| `<SpacetimeDBProvider builder={...}>` | `connectionBuilder={...}` | Wrong prop name |
---
## Hard Requirements
1. **`schema({ table })`** — use a single tables object; optional module settings are allowed as a second argument
2. **Reducer/procedure names from exports**`export const name = spacetimedb.reducer(params, fn)`; never `reducer('name', ...)`
3. **Reducer calls use object syntax**`{ param: 'value' }` not positional args
4. **Import `DbConnection` from `./module_bindings`** — not from `spacetimedb`
5. **DO NOT edit generated bindings** — regenerate with `spacetime generate`
6. **Indexes go in OPTIONS (1st arg)** — not in COLUMNS (2nd arg) of `table()`
7. **Use BigInt for u64/i64 fields**`0n`, `1n`, not `0`, `1`
8. **Reducers are transactional** — they do not return data
9. **Reducers must be deterministic** — no filesystem, network, timers, random
10. **Views should use index lookups**`.iter()` causes severe performance issues
11. **Procedures need `ctx.withTx()`**`ctx.db` doesn't exist in procedures
12. **Sum type values** — use `{ tag: 'variant', value: payload }` not `{ variant: payload }`
13. **Use `.withDatabaseName()`** — not `.withModuleName()` (2.0)
---
## Installation
```bash
npm install spacetimedb
```
For Node.js environments without native fetch/WebSocket support, install `undici`.
## Generating Type Bindings
```bash
spacetime generate --lang typescript --out-dir ./src/module_bindings --module-path ./server
```
## Client Connection
```typescript
import { DbConnection } from './module_bindings';
const connection = DbConnection.builder()
.withUri('ws://localhost:3000')
.withDatabaseName('my_database')
.withToken(localStorage.getItem('spacetimedb_token') ?? undefined)
.onConnect((conn, identity, token) => {
// identity: your unique Identity for this database
console.log('Connected as:', identity.toHexString());
// Save token for reconnection (preserves identity across sessions)
localStorage.setItem('spacetimedb_token', token);
conn.subscriptionBuilder()
.onApplied(() => console.log('Cache ready'))
.subscribe('SELECT * FROM player');
})
.onDisconnect((ctx) => console.log('Disconnected'))
.onConnectError((ctx, error) => console.error('Connection failed:', error))
.build();
```
## Subscribing to Tables
```typescript
// Basic subscription
connection.subscriptionBuilder()
.onApplied((ctx) => console.log('Cache ready'))
.subscribe('SELECT * FROM player');
// Multiple queries
connection.subscriptionBuilder()
.subscribe(['SELECT * FROM player', 'SELECT * FROM game_state']);
// Subscribe to all tables (development only — cannot mix with Subscribe)
connection.subscriptionBuilder().subscribeToAllTables();
// Subscription handle for later unsubscribe
const handle = connection.subscriptionBuilder()
.onApplied(() => console.log('Subscribed'))
.subscribe('SELECT * FROM player');
handle.unsubscribeThen(() => console.log('Unsubscribed'));
```
## Accessing Table Data
```typescript
for (const player of connection.db.player.iter()) { console.log(player.name); }
const players = Array.from(connection.db.player.iter());
const count = connection.db.player.count();
const player = connection.db.player.id.find(42n);
```
## Table Event Callbacks
```typescript
connection.db.player.onInsert((ctx, player) => console.log('New:', player.name));
connection.db.player.onDelete((ctx, player) => console.log('Left:', player.name));
connection.db.player.onUpdate((ctx, old, new_) => console.log(`${old.score} -> ${new_.score}`));
```
## Calling Reducers
**CRITICAL: Use object syntax, not positional arguments.**
```typescript
connection.reducers.createPlayer({ name: 'Alice', location: { x: 0, y: 0 } });
```
### Snake_case to camelCase conversion
- Server: `export const do_something = spacetimedb.reducer(...)`
- Client: `conn.reducers.doSomething({ ... })`
---
## Identity and Authentication
- `identity` and `token` are provided in the `onConnect` callback (see Client Connection above)
- `identity.toHexString()` for display or logging
- Omit `.withToken()` for anonymous connection — server assigns a new identity
- Pass a stale/invalid token: server issues a new identity and token in `onConnect`
---
## Error Handling
Connection-level errors (`.onConnectError`, `.onDisconnect`) are shown in the Client Connection example above.
```typescript
// Subscription error
connection.subscriptionBuilder()
.onApplied(() => console.log('Subscribed'))
.onError((ctx) => console.error('Subscription error:', ctx.event))
.subscribe('SELECT * FROM player');
```
---
## Server-Side Module Development
### Table Definition
```typescript
import { schema, table, t } from 'spacetimedb/server';
export const Task = table({
name: 'task',
public: true,
indexes: [{ name: 'task_owner_id', algorithm: 'btree', columns: ['ownerId'] }]
}, {
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
title: t.string(),
createdAt: t.timestamp(),
});
```
### Column types
```typescript
t.identity() // User identity
t.u64() // Unsigned 64-bit integer (use for IDs)
t.string() // Text
t.bool() // Boolean
t.timestamp() // Timestamp
t.scheduleAt() // For scheduled tables only
t.object('Name', {}) // Product types (nested objects)
t.enum('Name', {}) // Sum types (tagged unions)
t.string().optional() // Nullable
```
> BigInt syntax: All `u64`/`i64` fields use `0n`, `1n`, not `0`, `1`.
### Schema export
```typescript
const spacetimedb = schema({ Task, Player });
export default spacetimedb;
```
### Reducer Definition (2.0)
**Name comes from the export — NOT from a string argument.**
```typescript
import spacetimedb from './schema';
import { t, SenderError } from 'spacetimedb/server';
export const create_task = spacetimedb.reducer(
{ title: t.string() },
(ctx, { title }) => {
if (!title) throw new SenderError('title required');
ctx.db.task.insert({ id: 0n, ownerId: ctx.sender, title, createdAt: ctx.timestamp });
}
);
```
### Update Pattern
```typescript
const existing = ctx.db.task.id.find(taskId);
if (!existing) throw new SenderError('Task not found');
ctx.db.task.id.update({ ...existing, title: newTitle, updatedAt: ctx.timestamp });
```
### Lifecycle Hooks
```typescript
spacetimedb.clientConnected((ctx) => { /* ctx.sender is the connecting identity */ });
spacetimedb.clientDisconnected((ctx) => { /* clean up */ });
```
---
## Event Tables (2.0)
Reducer callbacks are removed in 2.0. Use event tables + `onInsert` instead.
```typescript
export const DamageEvent = table(
{ name: 'damage_event', public: true, event: true },
{ target: t.identity(), amount: t.u32() }
);
export const deal_damage = spacetimedb.reducer(
{ target: t.identity(), amount: t.u32() },
(ctx, { target, amount }) => {
ctx.db.damageEvent.insert({ target, amount });
}
);
```
Client subscribes and uses `onInsert`:
```typescript
conn.db.damageEvent.onInsert((ctx, evt) => {
playDamageAnimation(evt.target, evt.amount);
});
```
Event tables must be subscribed explicitly — they are excluded from `subscribeToAllTables()`.
---
## Views
### ViewContext vs AnonymousViewContext
```typescript
// ViewContext — has ctx.sender, result varies per user
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
return [...ctx.db.item.by_owner.filter(ctx.sender)];
});
// AnonymousViewContext — no ctx.sender, same result for everyone (better perf)
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(Player.rowType), (ctx) => {
return ctx.from.player.where(p => p.score.gt(1000));
});
```
Views can only use index lookups — `.iter()` is NOT allowed.
---
## Scheduled Tables
```typescript
export const CleanupJob = table({
name: 'cleanup_job',
scheduled: () => run_cleanup // function returning the exported reducer
}, {
scheduledId: t.u64().primaryKey().autoInc(),
scheduledAt: t.scheduleAt(),
targetId: t.u64(),
});
export const run_cleanup = spacetimedb.reducer(
{ arg: CleanupJob.rowType },
(ctx, { arg }) => { /* arg.scheduledId, arg.targetId available */ }
);
// Schedule a job
import { ScheduleAt } from 'spacetimedb';
ctx.db.cleanupJob.insert({
scheduledId: 0n,
scheduledAt: ScheduleAt.time(ctx.timestamp.microsSinceUnixEpoch + 60_000_000n),
targetId: someId
});
```
### ScheduleAt on Client
```typescript
// ScheduleAt is a tagged union on the client
// { tag: 'Time', value: Timestamp } or { tag: 'Interval', value: TimeDuration }
const schedule = row.scheduledAt;
if (schedule.tag === 'Time') {
const date = new Date(Number(schedule.value.microsSinceUnixEpoch / 1000n));
}
```
---
## Timestamps
### Server-side
```typescript
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n;
```
### Client-side
```typescript
// Timestamps are objects with BigInt, not numbers
const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
```
---
## Procedures (Beta)
```typescript
export const fetch_data = spacetimedb.procedure(
{ url: t.string() }, t.string(),
(ctx, { url }) => {
const response = ctx.http.fetch(url);
ctx.withTx(tx => { tx.db.myTable.insert({ id: 0n, content: response.text() }); });
return response.text();
}
);
```
Procedures don't have `ctx.db` — use `ctx.withTx(tx => tx.db...)`.
---
## React Integration
```tsx
import { useMemo } from 'react';
import { SpacetimeDBProvider, useTable } from 'spacetimedb/react';
import { DbConnection, tables } from './module_bindings';
function Root() {
const connectionBuilder = useMemo(() =>
DbConnection.builder()
.withUri('ws://localhost:3000')
.withDatabaseName('my_game')
.withToken(localStorage.getItem('auth_token') || undefined)
.onConnect((conn, identity, token) => {
localStorage.setItem('auth_token', token);
conn.subscriptionBuilder().subscribe(tables.player);
}),
[]
);
return (
<SpacetimeDBProvider connectionBuilder={connectionBuilder}>
<App />
</SpacetimeDBProvider>
);
}
function PlayerList() {
const [players, isReady] = useTable(tables.player);
if (!isReady) return <div>Loading...</div>;
return <ul>{players.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
```
---
## Project Structure
### Server (`backend/spacetimedb/`)
```
src/schema.ts -> Tables, export spacetimedb
src/index.ts -> Reducers, lifecycle, import schema
package.json -> { "type": "module", "dependencies": { "spacetimedb": "^2.0.0" } }
tsconfig.json -> Standard config
```
### Client (`client/`)
```
src/module_bindings/ -> Generated (spacetime generate)
src/main.tsx -> Provider, connection setup
src/App.tsx -> UI components
```
---
## Commands
```bash
spacetime start
spacetime publish <module-name> --module-path <backend-dir>
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
spacetime generate --lang typescript --out-dir <client>/src/module_bindings --module-path <backend-dir>
spacetime logs <module-name>
```

View File

@@ -1,292 +0,0 @@
---
name: spacetimedb-unity
description: Integrate SpacetimeDB with Unity game projects. Use when building Unity clients with MonoBehaviour lifecycle, FrameTick, and PlayerPrefs token persistence.
license: Apache-2.0
metadata:
author: clockworklabs
version: "2.0"
tested_with: "SpacetimeDB 2.0, Unity 2022.3+"
---
# SpacetimeDB Unity Integration
This skill covers Unity-specific patterns for connecting to SpacetimeDB. For server-side module development and general C# SDK usage, see the `spacetimedb-csharp` skill.
---
## HALLUCINATED APIs — DO NOT USE
```csharp
// WRONG — these do not exist in Unity SDK
SpacetimeDBClient.instance.Connect(...); // Use DbConnection.Builder()
SpacetimeDBClient.instance.Subscribe(...); // Use conn.SubscriptionBuilder()
NetworkManager.RegisterReducer(...); // SpacetimeDB is not a Unity networking plugin
// WRONG — old 1.0 patterns
.WithModuleName("my-db") // Use .WithDatabaseName() (2.0)
ScheduleAt.Time(futureTime) // Use new ScheduleAt.Time(futureTime)
```
---
## Common Mistakes
| Wrong | Right | Error |
|-------|-------|-------|
| Not calling `FrameTick()` | `conn?.FrameTick()` in `Update()` | No callbacks fire |
| Accessing `conn.Db` from background thread | Copy data in callback, use on main thread | Data races / crashes |
| Forgetting `DontDestroyOnLoad` | Add to manager `Awake()` | Connection lost on scene load |
| Connecting in `Update()` | Connect in `Start()` or on user action | Reconnects every frame |
| Not saving auth token | `PlayerPrefs.SetString(...)` in `OnConnect` | New identity every session |
| Missing generated bindings | Run `spacetime generate --lang csharp` | Compile errors |
---
## Installation
Add via Unity Package Manager using the git URL:
```
https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git
```
**Window > Package Manager > + > Add package from git URL**
---
## Generate Module Bindings
```bash
spacetime generate --lang csharp --out-dir Assets/SpacetimeDB/module_bindings --module-path PATH_TO_MODULE
```
Place generated files in your Assets folder so Unity compiles them.
---
## SpacetimeManager Singleton
The core pattern for Unity integration. This MonoBehaviour manages the connection lifecycle.
```csharp
using UnityEngine;
using SpacetimeDB;
using SpacetimeDB.Types;
public class SpacetimeManager : MonoBehaviour
{
private const string TOKEN_KEY = "SpacetimeAuthToken";
private const string SERVER_URI = "http://localhost:3000";
private const string DATABASE_NAME = "my-game";
public static SpacetimeManager Instance { get; private set; }
public DbConnection Connection { get; private set; }
public Identity LocalIdentity { get; private set; }
void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
}
void Start()
{
string savedToken = PlayerPrefs.GetString(TOKEN_KEY, null);
Connection = DbConnection.Builder()
.WithUri(SERVER_URI)
.WithDatabaseName(DATABASE_NAME)
.WithToken(savedToken)
.OnConnect(OnConnected)
.OnConnectError(err => Debug.LogError($"Connection failed: {err}"))
.OnDisconnect((conn, err) => {
if (err != null) Debug.LogError($"Disconnected: {err}");
})
.Build();
}
void Update()
{
Connection?.FrameTick();
}
void OnDestroy()
{
Connection?.Disconnect();
}
private void OnConnected(DbConnection conn, Identity identity, string authToken)
{
LocalIdentity = identity;
PlayerPrefs.SetString(TOKEN_KEY, authToken);
PlayerPrefs.Save();
Debug.Log($"Connected as: {identity}");
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.SubscribeToAllTables();
}
private void OnSubscriptionApplied(SubscriptionEventContext ctx)
{
Debug.Log("Subscription applied — game state loaded");
}
}
```
---
## FrameTick — Critical
**`FrameTick()` must be called every frame in `Update()`.** The SDK queues all network messages and only processes them when you call `FrameTick()`. Without it:
- No callbacks fire (OnInsert, OnUpdate, OnDelete, reducer callbacks)
- The client appears frozen
```csharp
void Update()
{
Connection?.FrameTick();
}
```
**Thread safety**: `FrameTick()` processes messages on the calling thread (the main thread in Unity). Do NOT call it from a background thread. Do NOT access `conn.Db` from background threads.
---
## Subscribing to Tables
Subscribe in the `OnConnected` callback:
```csharp
private void OnConnected(DbConnection conn, Identity identity, string authToken)
{
// ...save token...
// Development: subscribe to all
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.SubscribeToAllTables();
// Production: subscribe to specific tables
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.Subscribe(new[] {
"SELECT * FROM player",
"SELECT * FROM game_state"
});
}
```
---
## Row Callbacks for Game State
Register callbacks to update Unity GameObjects when table data changes.
```csharp
void RegisterCallbacks()
{
Connection.Db.Player.OnInsert += (EventContext ctx, Player player) => {
SpawnPlayerObject(player);
};
Connection.Db.Player.OnDelete += (EventContext ctx, Player player) => {
DestroyPlayerObject(player.Id);
};
Connection.Db.Player.OnUpdate += (EventContext ctx, Player oldPlayer, Player newPlayer) => {
UpdatePlayerObject(newPlayer);
};
}
```
Register these in `OnSubscriptionApplied` (after initial data is loaded) or in `Start()` before connecting.
---
## Calling Reducers from UI
```csharp
public class GameUI : MonoBehaviour
{
public void OnMoveButtonClicked(Vector2 direction)
{
SpacetimeManager.Instance.Connection.Reducers.MovePlayer(direction.x, direction.y);
}
public void OnSendChat(string message)
{
SpacetimeManager.Instance.Connection.Reducers.SendMessage(message);
}
}
```
### Reducer Callbacks
```csharp
SpacetimeManager.Instance.Connection.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => {
if (ctx.Event.Status is Status.Committed)
Debug.Log($"Message sent: {text}");
else if (ctx.Event.Status is Status.Failed(var reason))
Debug.LogError($"Send failed: {reason}");
};
```
---
## Reading the Client Cache
```csharp
// Find by primary key
if (Connection.Db.Player.Id.Find(playerId) is Player player)
{
Debug.Log($"Player: {player.Name}");
}
// Iterate all
foreach (var p in Connection.Db.Player.Iter())
{
Debug.Log(p.Name);
}
// Filter by index
foreach (var p in Connection.Db.Player.Level.Filter(5))
{
Debug.Log($"Level 5: {p.Name}");
}
// Count
int total = Connection.Db.Player.Count;
```
---
## Unity-Specific Considerations
### Main Thread Only
All SpacetimeDB SDK calls (`FrameTick`, `conn.Db` access, reducer calls) must happen on the main thread. If you need to pass data to a background thread, copy it first in the callback.
### Scene Loading
Use `DontDestroyOnLoad(gameObject)` on the SpacetimeManager to prevent the connection from being destroyed during scene transitions. Without it, the connection drops every time you load a new scene.
### IL2CPP / AOT
The SpacetimeDB SDK uses code generation. If you encounter issues with IL2CPP builds:
- Ensure generated bindings are up to date
- Check that `link.xml` preserves SpacetimeDB types if you use assembly stripping
### Token Persistence
Token save/load via `PlayerPrefs` is demonstrated in the SpacetimeManager singleton above. If the token is stale or invalid, the server issues a new identity and token in the `OnConnect` callback.
---
## Commands
```bash
spacetime start
spacetime publish <module-name> --module-path <backend-dir>
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
spacetime generate --lang csharp --out-dir Assets/SpacetimeDB/module_bindings --module-path <backend-dir>
spacetime logs <module-name>
```

View File

@@ -22,6 +22,7 @@ tmp
.env.secrets.*
spacetime.local.json
deploy/container/api-server.env
deploy/container/worker-smoke
server-rs/target
server-rs/target-*

3
.gitignore vendored
View File

@@ -37,11 +37,14 @@ temp*build*/
/.app/
/target/
/logs
/.codegraph/
/server-rs/crates/*/logs/
.worktrees/
.rag/
.env.secrets.local
spacetime.local.json
deploy/container/api-server.env
deploy/container/worker-smoke/
# Local load-test data extracted from private migration files
scripts/loadtest/data/*.local.json

View File

@@ -1,30 +1,21 @@
# Genarrative 团队 Hermes 共享记忆
# Genarrative Hermes 工具目录
本目录用于在仓库内共享团队级 Hermes 上下文,供 3 名开发人员在各自本地 Hermes 中读取、更新和同步
本目录只保留 Hermes 专用的仓库级工具资源,例如 Hermes skills、plugins 和启用说明。项目知识本体、长期记忆、计划和 TODO 不再放在 `.hermes/`,统一迁移到 `docs/project-memory/`
## 使用原则
- `.hermes/` 中只保存可以进入 Git 的团队共享内容。
- `.hermes/` 中只保存 Hermes 工具运行或加载所需内容。
- 项目长期知识、架构约定、排障经验、协作规则、计划和 TODO 统一放在 `docs/project-memory/`
- 不提交个人配置、API Key、会话转录、模型密钥、本地路径密钥等敏感内容。
- 个人 Hermes 的 `~/.hermes/config.yaml``~/.hermes/.env``~/.hermes/sessions/` 不应复制到本仓库。
- 开发前先阅读本目录下与任务相关的记忆文件;开发后如产生稳定知识,更新对应文档。
- 后续新增的 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`,便于团队跨目录检索。
-本目录内容与 `docs/` 或代码事实冲突,以当前代码和最新 `docs/` 为准,并同步修正过期记忆
- 阶段性计划、一次性 TODO 和已关闭实验不再长期沉淀为仓库文档;仍有效内容应合并进 `docs/` 当前融合文档或 `.hermes/shared-memory/`
- `.hermes/` 中的工具说明与代码或 `docs/` 冲突,以当前代码和最新 `docs/` 为准。
## 目录结构
```text
.hermes/
├─ README.md # 说明
├─ shared-memory/
│ ├─ project-overview.md # 项目概览与当前技术路线
│ ├─ team-conventions.md # 团队协作约定
│ ├─ development-workflow.md # 开发、测试、提交流程
│ ├─ document-map.md # README / AGENTS / docs 阅读索引
│ ├─ decision-log.md # 长期决策记录
│ ├─ pitfalls.md # 踩坑与排障记录
│ └─ handoff-template.md # 任务交接模板
├─ README.md # Hermes 工具目录说明
├─ skills/ # 仓库级 Hermes skills
└─ plugins/ # 仓库级 Hermes plugins需显式启用项目 plugin
```
@@ -71,22 +62,5 @@ HERMES_ENABLE_PROJECT_PLUGINS=1 HERMES_PLUGINS_DEBUG=1 hermes chat -q "请读取
在本仓库中开始复杂任务时,可以先对 Hermes 说:
```text
请先读取 AGENTS.md 以及 .hermes/shared-memory/ 下与本任务相关的团队共享记忆,再开始分析。若任务完成后产生稳定项目知识,请更新 .hermes/shared-memory/ 对应文件。
请先读取 AGENTS.md 以及 docs/project-memory/shared-memory/ 下与本任务相关的团队共享记忆,再开始分析。若任务完成后产生稳定项目知识,请更新 docs/project-memory/shared-memory/ 对应文件。
```
## 需要沉淀到这里的内容
- 长期有效的架构约定
- 反复会用到的本地开发/测试流程
- 已确认的接口契约或模块边界
- 重要技术决策及原因
- 踩坑、排障方式、验证命令
- 团队协作规则和任务交接规范
## 不应沉淀到这里的内容
- API Key、Token、Cookie、私有密钥
- 个人账号、个人本地绝对路径、个人隐私信息
- 大段临时聊天记录
- 尚未确认的一次性猜测
- 构建产物、日志、缓存、数据库 dump

View File

@@ -284,7 +284,7 @@ fn anonymous_user_cannot_publish_generated_draft() {
| 正式产品验收 / 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 目录。 |
| 稳定流程或团队经验 | `.hermes/shared-memory/``.hermes/skills/` | 不是某个功能验收,而是长期可复用的团队流程、坑点、执行规范。 |
| 稳定流程或团队经验 | `docs/project-memory/shared-memory/``.hermes/skills/` | 不是某个功能验收,而是长期可复用的团队流程、坑点、执行规范。 |
默认规则:
@@ -311,7 +311,7 @@ e2e/features/invite-code.feature
- 实施计划:当前任务上下文或 `.tmp/<task-name>.md`
- 产品/验收文档:当前 `docs/` 融合文档,必要时新增 `docs/【产品验收】中文标题-YYYY-MM-DD.md`
- 技术设计:当前 `docs/` 融合文档,必要时新增 `docs/【技术方案】中文标题-YYYY-MM-DD.md`
- 共享经验或稳定流程:`.hermes/shared-memory/``.hermes/skills/`
- 共享经验或稳定流程:`docs/project-memory/shared-memory/``.hermes/skills/`
BDD 文档建议包含:
@@ -389,4 +389,4 @@ npm run test -- --run <相关测试文件>
```
- [ ] 若涉及后端 Rust/API按相关 DDD/SpacetimeDB 文档运行对应 cargo/npm/API smoke 验证。
- [ ] 若产生长期有效经验,已同步到 `.hermes/shared-memory/` 或合适的仓库级 skill。
- [ ] 若产生长期有效经验,已同步到 `docs/project-memory/shared-memory/` 或合适的仓库级 skill。

View File

@@ -21,7 +21,7 @@ metadata:
- 处理 `3000``3101``3102``8082` 等端口被占用导致本地开发栈启动失败。
- 排查 Vite 代理仍指向旧 api-server 端口、前端打开了旧 dev server、后台代理错配。
- 调整 SpacetimeDB standalone、publish、Rust `api-server`、主站 Vite、后台 Vite 的启动顺序。
- 修改本地联调文档或 `.hermes/shared-memory/pitfalls.md` 中的 dev 启动口径。
- 修改本地联调文档或 `docs/project-memory/shared-memory/pitfalls.md` 中的 dev 启动口径。
## 当前端口职责
@@ -75,7 +75,7 @@ Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range`
- `scripts/dev.mjs`
- `scripts/dev-utils.mjs`
- `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`
- `.hermes/shared-memory/pitfalls.md`
- `docs/project-memory/shared-memory/pitfalls.md`
2. 优先改公共端口工具,不要把端口探测逻辑复制到多个脚本。
3. 修改 `scripts/dev.mjs` 时确认变量顺序:先解析参数和端口,再构造 `SPACETIME_SERVER` / `RUST_SERVER_TARGET`,最后启动对应 service。
4. 修改 watch 时保持模块边界SpacetimeDB 只监听 `spacetime-module` 且改动后重新 publish不重启 standalone 宿主api-server 排除 `spacetime-module`web/admin-web 源码变化交给 Vite 自身 HMR外层调度器不要再监听前端目录重启 Vite。
@@ -124,5 +124,5 @@ node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 ap
- [ ] `npm run dev` 的 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 都使用实际端口。
- [ ] `npm run dev:web` 在主站端口不可用时能切换到可用端口。
- [ ] 文档同步更新 `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`
- [ ] 长期踩坑同步更新 `.hermes/shared-memory/pitfalls.md`
- [ ] 长期踩坑同步更新 `docs/project-memory/shared-memory/pitfalls.md`
- [ ] 修改中文文件后运行 `npm run check:encoding`

View File

@@ -47,13 +47,13 @@ description: 在 Genarrative 新增、开放或重构玩法创作工具时,按
先读:
- `AGENTS.md`
- `.hermes/shared-memory/`
- `docs/project-memory/shared-memory/`
- `CONTEXT.md`
- `docs/README.md`
- `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
- 相关玩法 PRD 或设计文档
如果文档不能精确指导字段、契约、资产槽位、生成流程和恢复语义,先补文档再编码。新增长期约定时同步 `.hermes/shared-memory/`
如果文档不能精确指导字段、契约、资产槽位、生成流程和恢复语义,先补文档再编码。新增长期约定时同步 `docs/project-memory/shared-memory/`
### 2. 定玩法边界

View File

@@ -28,7 +28,7 @@
11.`api-server`
- `src/runtime_profile.rs`Query params / parser / handler / response builder。
- `src/app.rs`:挂路由,例如 profile 或 admin analytics endpoint选择路径前确认产品定位。
12. 最后更新当前 `docs/` 文档和必要的 `.hermes/shared-memory/` 摘要,并确认 diff 不只是生成物。
12. 最后更新当前 `docs/` 文档和必要的 `docs/project-memory/shared-memory/` 摘要,并确认 diff 不只是生成物。
## 验证命令示例

View File

@@ -1,16 +1,24 @@
# AGENTS.md
## 团队 Hermes 共享记忆
- 本仓库的团队级 Hermes 共享内容位于 [`.hermes/`](.hermes/),用于在 3 名开发人员各自本地 Hermes 之间同步长期项目记忆
## 项目共享记忆
- 本仓库的团队级项目记忆位于 [`docs/project-memory/`](docs/project-memory/),用于在 3 名开发人员各自本地 Agent 之间同步长期项目知识
- [`.hermes/`](.hermes/) 只保存 Hermes 专用的仓库级工具资源,例如 skills、plugins 和启用说明,不作为项目知识库。
- 开始复杂开发任务前,除阅读本文件外,还应优先读取:
- [`.hermes/README.md`](.hermes/README.md)
- [`.hermes/shared-memory/project-overview.md`](.hermes/shared-memory/project-overview.md)
- [`.hermes/shared-memory/team-conventions.md`](.hermes/shared-memory/team-conventions.md)
- [`.hermes/shared-memory/development-workflow.md`](.hermes/shared-memory/development-workflow.md)
- 与任务相关的 [`.hermes/shared-memory/decision-log.md`](.hermes/shared-memory/decision-log.md) 和 [`.hermes/shared-memory/pitfalls.md`](.hermes/shared-memory/pitfalls.md)
- 如果本次任务产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则,应同步更新 `.hermes/shared-memory/` 中对应文件。
- 仓库 `.hermes/` 只保存可进入 Git 的团队共享内容;禁止提交个人 `~/.hermes` 配置、`.env`、API Key、Token、会话记录、认证文件和本地私密路径
- `.hermes/shared-memory/` 与当前代码或 `docs/` 最新文档冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆
- [`docs/project-memory/README.md`](docs/project-memory/README.md)
- [`docs/project-memory/shared-memory/project-overview.md`](docs/project-memory/shared-memory/project-overview.md)
- [`docs/project-memory/shared-memory/team-conventions.md`](docs/project-memory/shared-memory/team-conventions.md)
- [`docs/project-memory/shared-memory/development-workflow.md`](docs/project-memory/shared-memory/development-workflow.md)
- 与任务相关的 [`docs/project-memory/shared-memory/decision-log.md`](docs/project-memory/shared-memory/decision-log.md) 和 [`docs/project-memory/shared-memory/pitfalls.md`](docs/project-memory/shared-memory/pitfalls.md)
- 如果本次任务产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则,应同步更新 `docs/project-memory/shared-memory/` 中对应文件
- 禁止提交个人 `~/.hermes` 配置、`.env`、API Key、Token、会话记录、认证文件和本地私密路径
-`docs/project-memory/shared-memory/` 与当前代码或 `docs/` 最新文档冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。
## Agent 本地 RAG
- 本仓库提供面向 Agent 的本地文档 RAG入口位于 [`scripts/rag/`](scripts/rag/)RAG 主要用于 Agent 检索项目上下文,不替代人工阅读 `AGENTS.md``docs/README.md``docs/project-memory/`
- 开始复杂任务、跨模块任务或不确定文档入口时Agent 可先用 `npm run rag:search -- --query "问题或关键词" --limit 8 --max-chars 12000` 取候选上下文;需要刷新索引时运行 `npm run rag:index`
- RAG 输出只作为候选上下文。涉及精确代码或文档修改时,仍需打开对应源文件核对;来源冲突时,以当前代码和最新 `docs/` 为准。
- 默认不安装 RAG 运行时依赖,也不把 LanceDB、Transformers.js 或本地 embedding 模型写入根 `package.json`。需要启用时Agent 必须先询问用户是否安装,并在确认后只安装到 gitignored 的 `.rag/runtime/`;详细命令见 [`scripts/rag/README.md`](scripts/rag/README.md)。
## Agent skills
@@ -67,11 +75,10 @@ Single-context layout: read root `CONTEXT.md` when present. Current architecture
- [$spacetimedb-cli](.codex\\skills\\spacetimedb-cli\\SKILL.md)
- [$spacetimedb-rust](.codex\\skills\\spacetimedb-rust\\SKILL.md)
- [$spacetimedb-concepts](.codex\\skills\\spacetimedb-concepts\\SKILL.md)
- [$spacetimedb-typescript](.codex\\skills\\spacetimedb-typescript\\SKILL.md)
- 涉及 `spacetime` CLI、发布、绑定生成、本地联调时`spacetimedb-cli` 执行。
- 涉及 `npm run dev` / `npm run dev:rust` / `npm run dev: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) 执行。
- 涉及 `crates/spacetime-module` 的表、reducer、view、Rust API 使用时,按 `spacetimedb-rust``spacetimedb-concepts` 执行。
- 涉及前端或 Node 侧的 SpacetimeDB TypeScript SDK、订阅、绑定使用时`spacetimedb-typescript``spacetimedb-concepts` 执行
- 涉及前端或 Node 侧的 SpacetimeDB 订阅、绑定使用时,按当前生成绑定、项目代码和官方文档核对;本仓库不再维护单独 TypeScript / C# / Unity SpacetimeDB skill
- 若仓库内旧实现或旧文档与这些 skill 冲突,先修正文档和方案,再继续编码。
- 修改后端代码后,必须使用 `npm run api-server` 自动重新运行后端,并执行相应自动测试;不要再使用旧的后端重启命令。
- 数据库表结构更改后需要对齐migration.rs

View File

@@ -9,12 +9,14 @@ Docker Compose
├─ spacetimedb :3101独立数据卷供 api-server 连接
├─ nginx :80 -> api-server:8082负责静态站点、/admin/、/api/ 反代、upstream timing log、连接限制
├─ api-server :8082Linux release 构建,连接 compose 内 SpacetimeDB
├─ external-generation-worker独立 worker 进程,消费 external_generation_job 队列
├─ otelcol :4317/4318debug exporter接收 traces / metrics / logs
└─ k6 profile=loadtest 时临时启动,在 compose 网络内压 nginx
```
当前容器模拟参数按 `genarrative-release` 服务器采样值收口为 2 vCPU / 2 GiB RAM / 4096 soft nofile / 768 worker_connections并已在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m``api-server cpus=2.0 mem_limit=1g``nginx cpus=0.5 mem_limit=128m``otelcol cpus=0.25 mem_limit=128m``k6 cpus=1.0 mem_limit=512m`。SpacetimeDB 同时设置 `--page_pool_max_size=402653184`,给 reducer、订阅与运行时保留更多非 page pool 内存。
当前容器模拟参数按 `genarrative-release` 服务器采样值收口为 2 vCPU / 2 GiB RAM / 4096 soft nofile / 768 worker_connections并已在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m``api-server cpus=2.0 mem_limit=1g``external-generation-worker cpus=2.0 mem_limit=1g``nginx cpus=0.5 mem_limit=128m``otelcol cpus=0.25 mem_limit=128m``k6 cpus=1.0 mem_limit=512m`。SpacetimeDB 同时设置 `--page_pool_max_size=402653184`,给 reducer、订阅与运行时保留更多非 page pool 内存。
容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,用于让 Tokio 在 2 vCPU 配额内有更多 I/O 调度 worker该值不会突破 compose 里的 `cpus=2.0` CPU 上限。
容器默认 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,用于验证 `api-server -> external_generation_job -> external-generation-worker` 链路;如只想本地同步排查 provider/OSS/SpacetimeDB 写回,可在本机 env 临时改为 `inline`,但该模式不会覆盖 worker 动态扩缩容验证。
Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`
生产服务器若启用 Collector则由 `deploy/systemd/otelcol-contrib.service``deploy/otelcol/genarrative-debug.yaml` 托管,不走容器镜像。
@@ -55,7 +57,7 @@ Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `ht
## 构建工具链
`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.4.1` 依赖链要求 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。
`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.4.1` 依赖链要求 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`镜像构建阶段会同时复制 `public/`,用于满足 API 二进制里 `include_bytes!` 引用的内置素材;不要把 `public/generated-*` 放入镜像上下文。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。
## 启动与验证
@@ -74,6 +76,7 @@ curl -sS http://127.0.0.1:18080/api/runtime/puzzle/gallery
```bash
npm run container:logs -- nginx
npm run container:logs -- api-server
npm run container:logs -- external-generation-worker
npm run container:logs -- otelcol
```
@@ -85,6 +88,73 @@ npm run container:config -- --print
如果 `deploy/container/api-server.env` 已写入真实 token不要把完整展开结果贴到公开渠道。
动态扩缩容外部生成 worker 时,只调整 `external-generation-worker` service
```bash
npm run container:up -- --scale external-generation-worker=3 external-generation-worker
npm run container:up -- --scale external-generation-worker=1 external-generation-worker
```
动态扩缩容验证必须保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue``inline` 模式下生成请求由 `api-server` 同步执行,不会被这些 worker 实例消费。
### 外部生成 Worker 隔离 Smoke
如果只想在本机隔离验证 worker 模式,不复用 `deploy/container/api-server.env`,使用专用脚本:
```bash
npm run container:worker-smoke -- smoke
```
该脚本会生成 gitignored 的 `deploy/container/worker-smoke/api-server.env` 与端口 state使用独立 compose project、独立 SpacetimeDB 数据卷和独立 host 端口,完成 `build -> up-spacetime -> publish -> up -> enqueue -> api-update -> enqueue`。测试 job 使用 `worker_smoke_unsupported` 类型,不访问真实 VectorEngine、LLM 或 OSS预期结果是 worker 领取队列任务后按“不支持的任务类型”执行失败分支,从而验证队列 claim、lease、失败回写路径和 API / worker 进程隔离。`external_generation_job` 是 private table脚本通过 worker 日志里的 job_id 和 unsupported 记录确认消费,不通过 CLI SQL 绕过权限。`smoke` 默认只启动 `api-server``external-generation-worker`,避免无关前端 / Nginx 镜像构建;需要同时验证 Nginx 时可分步执行 `up --with-nginx`
分步排查时可执行:
```bash
npm run container:worker-smoke -- init --force
npm run container:worker-smoke -- build
npm run container:worker-smoke -- up-spacetime
npm run container:worker-smoke -- publish
npm run container:worker-smoke -- up
npm run container:worker-smoke -- enqueue before-update
npm run container:worker-smoke -- api-update
npm run container:worker-smoke -- enqueue after-update
npm run container:worker-smoke -- status
```
如果隔离端口或库数据需要重置:
```bash
npm run container:worker-smoke -- smoke --force
```
`container:worker-smoke` 默认会把本机 `spacetime` 2.4.1 CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 必须拉取官方大镜像;普通 `npm run container:*` 压测仍默认使用 `clockworklabs/spacetime:v2.4.1`。如果 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
```
`api-update` 只会 `--force-recreate api-server`,并校验 `external-generation-worker` 容器 ID 不变;如要同时重建 API 镜像,使用:
```bash
npm run container:worker-smoke -- api-update --build
```
验证 worker 动态扩缩容:
```bash
npm run container:worker-smoke -- scale 3
npm run container:worker-smoke -- ps
npm run container:worker-smoke -- enqueue scaled-workers
npm run container:worker-smoke -- scale 1
```
查看或清理隔离环境:
```bash
npm run container:worker-smoke -- logs external-generation-worker
npm run container:worker-smoke -- down -v
```
停止:
```bash

View File

@@ -2,6 +2,7 @@ FROM rust:1.93-bookworm AS rust-builder
WORKDIR /workspace
COPY server-rs ./server-rs
COPY public ./public
RUN cargo build --release -p api-server --manifest-path server-rs/Cargo.toml && \
cp server-rs/target/release/api-server /tmp/api-server

View File

@@ -8,6 +8,14 @@ GENARRATIVE_API_PORT=8082
GENARRATIVE_API_LOG=info,tower_http=info
GENARRATIVE_API_LISTEN_BACKLOG=1024
GENARRATIVE_API_WORKER_THREADS=4
# 容器 smoke 可临时设 all压测或预发按 api / external-generation-worker 拆进程。
GENARRATIVE_PROCESS_ROLE=api
# 默认 queue 进入 external_generation_job本地/小流量同步排查可显式设 inline。
GENARRATIVE_EXTERNAL_GENERATION_MODE=queue
GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64

View File

@@ -2,7 +2,7 @@ name: genarrative-container-loadtest
services:
spacetimedb:
image: clockworklabs/spacetime:v2.4.1
image: ${GENARRATIVE_CONTAINER_SPACETIME_IMAGE:-clockworklabs/spacetime:v2.4.1}
user: root
command:
[
@@ -44,7 +44,7 @@ services:
cpus: "2.0"
mem_limit: 1g
env_file:
- ./api-server.env
- ${GENARRATIVE_CONTAINER_API_ENV_FILE:-./api-server.env}
environment:
GENARRATIVE_API_HOST: 0.0.0.0
GENARRATIVE_API_PORT: 8082
@@ -69,6 +69,32 @@ services:
retries: 12
start_period: 20s
external-generation-worker:
build:
context: ../..
dockerfile: deploy/container/api-server.Dockerfile
target: api-runtime
cpus: "2.0"
mem_limit: 1g
env_file:
- ${GENARRATIVE_CONTAINER_API_ENV_FILE:-./api-server.env}
environment:
GENARRATIVE_PROCESS_ROLE: external-generation-worker
GENARRATIVE_TRACKING_OUTBOX_DIR: /var/lib/genarrative/tracking-outbox-worker
OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4318
OTEL_SERVICE_NAME: genarrative-external-generation-worker
extra_hosts:
- "host.docker.internal:host-gateway"
ulimits:
nofile:
soft: 4096
hard: 4096
depends_on:
spacetimedb:
condition: service_healthy
otelcol:
condition: service_started
nginx:
build:
context: ../..

View File

@@ -7,6 +7,14 @@ GENARRATIVE_API_PORT=8082
GENARRATIVE_API_LOG=info,tower_http=info
GENARRATIVE_API_LISTEN_BACKLOG=1024
GENARRATIVE_API_WORKER_THREADS=4
# api 只监听 HTTP外部生成 worker 用独立进程设置为 external-generation-worker 后横向扩缩。
GENARRATIVE_PROCESS_ROLE=api
# 默认 queue 进入 external_generation_job本地/小流量同步排查可显式设 inline。
GENARRATIVE_EXTERNAL_GENERATION_MODE=queue
GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64

View File

@@ -0,0 +1,13 @@
# 复制到 /etc/genarrative/external-generation-controller.env 后按机器容量调整。
# controller 只管理 systemd worker 实例SpacetimeDB、外部 provider 密钥继续复用 api-server.env。
# systemd unit 会强制设置 GENARRATIVE_PROCESS_ROLE=external-generation-controller。
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MIN_WORKERS=1
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MAX_WORKERS=8
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_TARGET_JOBS_PER_WORKER=2
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_POLL_INTERVAL_MS=10000
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SCALE_DOWN_IDLE_ROUNDS=6
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SERVICE_TEMPLATE=genarrative-external-generation-worker@{}.service
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_DRY_RUN=false
GENARRATIVE_API_LOG=info,tower_http=info
OTEL_SERVICE_NAME=genarrative-external-generation-controller

View File

@@ -0,0 +1,11 @@
# 复制到 /etc/genarrative/external-generation-worker.env 后按机器容量调整。
# 该文件只覆盖 worker 专属参数SpacetimeDB、外部 provider 密钥继续复用 api-server.env。
# systemd 模板会强制设置 GENARRATIVE_PROCESS_ROLE=external-generation-worker
# 和 GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=%H-%i避免多实例 ID 冲突。
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
# 单次 lease 会由 worker 自动续租;该值覆盖心跳抖动窗口即可。
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600
GENARRATIVE_API_LOG=info,tower_http=info
OTEL_SERVICE_NAME=genarrative-external-generation-worker

View File

@@ -0,0 +1,28 @@
[Unit]
Description=Genarrative External Generation Worker Controller
After=network-online.target spacetimedb.service
Wants=network-online.target
Requires=spacetimedb.service
[Service]
Type=simple
WorkingDirectory=/opt/genarrative/current
EnvironmentFile=/etc/genarrative/api-server.env
EnvironmentFile=-/etc/genarrative/external-generation-controller.env
Environment="LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib"
ExecStart=/usr/bin/env GENARRATIVE_PROCESS_ROLE=external-generation-controller GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox/controller OTEL_SERVICE_NAME=genarrative-external-generation-controller /opt/genarrative/current/api-server
Restart=always
RestartSec=5
KillSignal=SIGINT
TimeoutStopSec=120
LimitNOFILE=65535
TasksMax=512
# controller 需要调用 systemctl 管理 worker@N 实例,因此不降为 genarrative 用户。
# 它只复用 api-server 发布包和 SpacetimeDB 配置,不直接执行外部生成任务。
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/opt/genarrative /var/lib/genarrative
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,30 @@
[Unit]
Description=Genarrative External Generation Worker %i
After=network-online.target spacetimedb.service
Wants=network-online.target
Requires=spacetimedb.service
[Service]
Type=simple
User=genarrative
Group=genarrative
WorkingDirectory=/opt/genarrative/current
EnvironmentFile=/etc/genarrative/api-server.env
EnvironmentFile=-/etc/genarrative/external-generation-worker.env
Environment="LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib"
ExecStart=/usr/bin/env GENARRATIVE_PROCESS_ROLE=external-generation-worker GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=%H-%i GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox/%H-%i OTEL_SERVICE_NAME=genarrative-external-generation-worker /opt/genarrative/current/api-server
Restart=always
RestartSec=5
KillSignal=SIGINT
TimeoutStopSec=7200
LimitNOFILE=65535
TasksMax=2048
# worker 复用 api-server 发布目录;外部生成审计与临时运行态只写服务端私有目录。
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/opt/genarrative /var/lib/genarrative
[Install]
WantedBy=multi-user.target

View File

@@ -85,7 +85,7 @@ RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/compon
平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。
平台 UI Kit 的提示 / 确认弹窗收口到 `src/components/common/UnifiedConfirmDialog.tsx`,复制反馈收口到 `src/components/common/useCopyFeedback.ts``src/components/common/CopyFeedbackButton.tsx``src/components/common/CopyCodeButton.tsx``src/components/common/CopyFeedbackMessage.tsx`,基础状态提示收口到 `src/components/common/PlatformStatusMessage.tsx`,运行态短错误 / 成功 / 反馈 toast 收口到 `src/components/common/PlatformRuntimeStatusToast.tsx`,平台空态 / 轻量加载态收口到 `src/components/common/PlatformEmptyState.tsx`,平台动作按钮收口到 `src/components/common/PlatformActionButton.tsx`,平台白底子面板 / 小型列表卡片收口到 `src/components/common/PlatformSubpanel.tsx`,平台输入框 / 文本域收口到 `src/components/common/PlatformTextField.tsx`,平台字段标题收口到 `src/components/common/PlatformFieldLabel.tsx`,平台媒体预览框收口到 `src/components/common/PlatformMediaFrame.tsx`,平台胶囊状态标签收口到 `src/components/common/PlatformPillBadge.tsx`,平台 / 个人中心弹窗关闭按钮收口到 `src/components/common/PlatformModalCloseButton.tsx`,底层继续复用 `UnifiedModal`;普通提示、确认 / 取消、危险确认、复制状态机、短代码复制 chip、复制按钮表现、白底 / 个人中心 / 认证入口 token 状态条、运行态状态 toast、无操作空态、主动作按钮、白底子面板、白底交互列表卡片、普通输入字段、字段标题、图片源 / fallback / 固定比例媒体预览、单个状态 / 标签 chip 和圆形关闭按钮优先使用公共 Module规则见 [【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md](./technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md)。
平台 UI Kit 的提示 / 确认弹窗收口到 `src/components/common/UnifiedConfirmDialog.tsx`,复制反馈收口到 `src/components/common/useCopyFeedback.ts``src/components/common/CopyFeedbackButton.tsx``src/components/common/CopyCodeButton.tsx``src/components/common/CopyFeedbackMessage.tsx`,基础状态提示收口到 `src/components/common/PlatformStatusMessage.tsx`,运行态短错误 / 成功 / 反馈 toast 收口到 `src/components/common/PlatformRuntimeStatusToast.tsx`,平台空态 / 轻量加载态收口到 `src/components/common/PlatformEmptyState.tsx`,平台动作按钮收口到 `src/components/common/PlatformActionButton.tsx`,平台白底子面板 / 小型列表卡片收口到 `src/components/common/PlatformSubpanel.tsx`,平台输入框 / 文本域收口到 `src/components/common/PlatformTextField.tsx`,平台字段标题收口到 `src/components/common/PlatformFieldLabel.tsx`,平台媒体预览框收口到 `src/components/common/PlatformMediaFrame.tsx`,平台胶囊状态标签收口到 `src/components/common/PlatformPillBadge.tsx`,平台图片全屏预览收口到 `src/components/common/PlatformImagePreviewModal.tsx`,平台 / 个人中心弹窗关闭按钮收口到 `src/components/common/PlatformModalCloseButton.tsx`,底层继续复用 `UnifiedModal`;普通提示、确认 / 取消、危险确认、复制状态机、短代码复制 chip、复制按钮表现、白底 / 个人中心 / 认证入口 token 状态条、运行态状态 toast、无操作空态、主动作按钮、白底子面板、白底交互列表卡片、普通输入字段、字段标题、图片源 / fallback / 固定比例媒体预览、全屏黑底图片查看、单个状态 / 标签 chip 和圆形关闭按钮优先使用公共 Module规则见 [【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md](./technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md)。
平台入口受保护数据失效后的 stage 去留判定,以及缺失草稿 / 作品 / run 时的阶段回退,收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空、布尔事实汇总和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。

View File

@@ -9,5 +9,5 @@
## 维护规则
- 计划文档只记录可执行阶段、负责人切分、验收门禁和当前状态。
- 已经稳定为长期约定的内容,应同步沉淀到 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``.hermes/shared-memory/`
- 已经稳定为长期约定的内容,应同步沉淀到 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/project-memory/shared-memory/`
- 若代码事实与计划冲突,以代码和当前融合文档为准,并回写更新本目录。

View File

@@ -27,7 +27,7 @@
| 阶段 | 状态 | 说明 |
| --- | --- | --- |
| Phase 0 总计划与门禁 | 已完成 | 本文档、`docs/planning/README.md``docs/README.md``.hermes/shared-memory/document-map.md` 已补齐入口;后续按 phase 扩展门禁。 |
| Phase 0 总计划与门禁 | 已完成 | 本文档、`docs/planning/README.md``docs/README.md``docs/project-memory/shared-memory/document-map.md` 已补齐入口;后续按 phase 扩展门禁。 |
| Phase 1 首批统一壳 | 已收口 | `puzzle``match3d``jump-hop``wooden-fish` 已接入 `UnifiedCreationPage` / `UnifiedGenerationPage`,竖屏滚动和字段契约已回归。 |
| Phase 1 补充统一壳 | 已收口 | `jump-hop` 也已接入 `UnifiedCreationPage` / `UnifiedGenerationPage`,统一创作页现在接管拼图、抓大鹅、跳一跳和敲木鱼四条入口的可见外壳与滚动。 |
| Phase 2 契约与配置治理 | 已完成 | `creationTypes[].unifiedCreationSpec`、前端 fallback、后台配置校验和文档门禁已按现有测试与 schema 检查收口。 |
@@ -60,7 +60,7 @@
状态:已完成。
- 新增本计划文档和 `docs/planning/README.md`,并在 `docs/README.md``.hermes/shared-memory/document-map.md` 中补上规划入口。
- 新增本计划文档和 `docs/planning/README.md`,并在 `docs/README.md``docs/project-memory/shared-memory/document-map.md` 中补上规划入口。
- 补齐 `当前进度``执行轮次` 和可并行任务表,后续每个 phase 完成后更新本文档的状态、验收命令和风险。
退出条件:

View File

@@ -0,0 +1,32 @@
# 项目记忆目录
本目录保存可以进入 Git 的项目级长期知识,供开发者和 Agent 读取。`.hermes/` 只保留 Hermes 工具专用资源,不再作为项目知识库。
## 目录结构
```text
docs/project-memory/
├─ README.md
├─ shared-memory/
│ ├─ project-overview.md
│ ├─ team-conventions.md
│ ├─ development-workflow.md
│ ├─ document-map.md
│ ├─ decision-log.md
│ ├─ pitfalls.md
│ └─ handoff-template.md
├─ plans/
└─ todos/
```
## 使用原则
- 开发前先读 `AGENTS.md`,再按任务读取 `docs/project-memory/shared-memory/` 和当前 `docs/` 文档。
- 长期有效的架构约定、接口变化、排障经验、开发流程和协作规则写入 `shared-memory/`
- 阶段性计划写入 `plans/`,已确定但暂未实施的共享 TODO 写入 `todos/`
- 如果本目录内容与代码或最新 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正过期记忆。
- 禁止写入个人配置、API Key、Token、Cookie、会话记录、认证文件、本地私密路径、构建产物、日志、缓存和数据库 dump。
## RAG 索引
本目录是 Agent 本地 RAG 的高权重索引源。RAG 主要用于 Agent 检索上下文,不替代人工阅读入口或正式文档地图。索引脚本位于 `scripts/rag/`,本地生成的 `.rag/` 数据不提交 Git。

View File

@@ -16,6 +16,38 @@
---
## 2026-06-15 SpacetimeDB 本地 skills 只保留 CLI / Concepts / Rust
- 背景:本仓库的 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`
## 2026-06-13 图片大图预览统一为黑底全屏查看器
- 背景:`CreativeImageInputPanel` 的参考图 / 主图预览曾使用白底 `UnifiedModal` 工具弹窗,移动端会透出原页面背景,且不能全屏查看、缩放或拖拽细节。
- 决策:纯图片大图预览统一使用 `src/components/common/PlatformImagePreviewModal.tsx`。该组件底层复用 `UnifiedModal` 的 dialog / portal / Escape 语义,但视觉上固定为黑底全屏查看器;图片按视口 contain 初始完整展示,缩放范围固定 `1x-4x`,拖拽位移按缩放后的图片边界夹取,避免露出背景。裁剪、选择、编辑等工具语义仍继续使用白底工具弹窗,不并入图片查看器。
- 影响范围:`CreativeImageInputPanel` 的参考图预览、主图预览,以及后续 common 级图片查看场景。
- 验证方式:`npm run test -- src/components/common/PlatformImagePreviewModal.test.tsx src/components/common/CreativeImageInputPanel.test.tsx``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/README.md``docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md`
## 2026-06-13 外部生成队列概览归属“我的”页签
- 背景:外部生成 worker 队列从单个生成页等待信息扩展为当前账号级别的后台排队 / 生成概览;继续放在生成页 / 进度页会把账号级队列与当前玩法业务进度混在一起。
- 决策:移动端用户可见的外部生成队列概览统一放在一级 `我的` 页签;生成页 / 进度页只展示当前玩法的阶段、步骤、总进度、错误和重试动作。队列概览只读取 BFF `GET /api/runtime/external-generation/queue-overview` 与当前前端已知单 job 状态作为等待补充,不替代玩法 session/detail 的 ready / failed 回读。
- 影响范围:平台入口壳层轮询条件、`RpgEntryHomeView` 我的页卡片、共用生成页 `CustomWorldGenerationView` / `UnifiedGenerationPage`、外部生成 worker 技术文档和本地开发验证文档。
- 验证方式:生成页不出现“生成队列”区域;登录用户进入“我的”页且队列有 pending/running 或当前 job 为 queued/running/failed 时显示队列卡;退出登录或切换账号时不保留旧账号队列概览。前端验证运行 `npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts src/components/unified-creation/UnifiedGenerationPage.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`
## 2026-06-12 外部生成 worker 扩展到跳一跳、拼消消和敲木鱼
- 背景:外部图片生成已从 HTTP 长请求迁到 `external_generation_job` 队列;跳一跳、拼消消和敲木鱼继续扩展时需要统一 job 粒度、前端等待展示和本地 / 生产验证口径。
- 决策:队列 BFF 暴露用户可见队列概览 `GET /api/runtime/external-generation/queue-overview` 和单 job 状态 `GET /api/runtime/external-generation/jobs/{jobId}`;首版固定“单动作单 job”不拆提示词 / 生图 / 切图 / 持久化等阶段 job。进入队列的范围为跳一跳 `compile-draft` / `regenerate-tiles`、拼消消 `compile-draft` / `regenerate-atlas`、敲木鱼 `compile-draft` / `regenerate-hit-object` 图片资产动作;非外部图片生成动作继续 inline。
- 影响范围:外部生成 worker Module、api-server BFF、生成页等待展示、跳一跳 / 拼消消 / 敲木鱼创作与结果页生成动作、本地和生产验证文档。
- 验证方式:本地 `npm run dev` 默认保留 inline 开发体验;验证 worker 队列、等待展示、lease 或扩缩容时显式使用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 并启动 worker或运行 `npm run container:worker-smoke -- smoke`。部署后确认 `/healthz``/readyz`、队列概览 BFF、单 job 状态和对应玩法 session/detail 状态都能收敛。
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-06-11 本地服务器管理入口采用 SSH alias + egui 桌面面板
- 背景release / dev 等服务器的日常巡检已有 systemd、健康巡检 timer 和 HTTP 探测口径,但开发者本地仍需要在多个 SSH alias 间手工切换命令并重复执行启停操作。
@@ -40,6 +72,13 @@
- 验证方式:从 release 执行 `git ls-remote http://10.2.0.10/GenarrativeAI/Genarrative.git HEAD` 应返回 HEAD公网来源伪造 `Host: 10.2.0.10` 访问 dev 公网 80 应返回 `403``https://git.genarrative.world/` 原入口应保持 `200`
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-06-08 通用分享统一为作品分享卡片
- 背景:已发布作品的分享入口需要同时支持网页复制链接、下载可传播的分享卡,以及微信小程序内的九宫切图;推荐页在小程序内直接使用系统“分享到聊天”时,宿主快照只截页面中部,容易裁掉游戏主体,且原生分享默认只能拿到小程序页面启动参数。
- 决策:统一分享入口继续收口到 `PublishShareModal`,分享卡展示作品封面、作品类型、作品名称和公开作品号,底部提供“复制链接”和“下载卡片”。普通 H5 复制公开作品 H5 URL微信小程序 WebView 内复制小程序 `pages/web-view/index` 路径,缺少直达参数时补 `targetPath=/works/detail``work=<公开作品号>`,由小程序原生 WebView 页转成 H5 作品详情 URL。当 H5 运行在微信小程序 WebView 内且存在封面图时,额外显示“九宫切图”,跳转小程序原生 `pages/share-grid/index`,由原生页按 3x3 从左到右、从上到下裁切并保存。推荐页当前作品会通过 `wx.miniProgram.postMessage` 同步给小程序原生 `web-view` 页,右上角系统分享优先使用该目标生成带作品参数的小程序路径。小程序运行态通过根节点标记启用推荐页 runtime 快照安全区,把游戏画面等比缩放到分享快照中部。
- 影响范围:`src/components/common/PublishShareModal.tsx``src/components/common/publishShareModalModel.ts``src/components/common/publishShareCardImage.ts``src/services/wechatMiniProgramShareGrid.ts``src/services/wechatMiniProgramShareTarget.ts``miniprogram/pages/web-view/``miniprogram/pages/share-grid/`、推荐页 runtime CSS 和平台玩法链路文档。
- 验证方式:`npm run test -- src/components/common/PublishShareModal.test.tsx miniprogram/pages/web-view/index.test.js src/services/wechatMiniProgramShareTarget.test.ts``npm run test -- miniprogram/pages/share-grid/index.test.js``npm run test -- src/index.test.ts -t "mini program recommend runtime"``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-08 微信能力按领域收口
- 背景:微信登录、订阅消息、普通微信支付和小程序虚拟支付能力曾分散在 `api-server` 根模块、`platform-auth``platform-wechat`,支付协议细节和业务 handler 边界不够清晰。
@@ -534,7 +573,7 @@
## 2026-06-05 Server-Provision 全程在目标部署 agent 执行且不安装构建链
- 背景:`Genarrative-Server-Provision``DEPLOY_TARGET=development` 语义是部署到 dev 服务器,不是构建机 dry-run。旧流水线把 development 映射到 `linux && genarrative-build`,还先在 build 节点准备 `provision-tools/` 再 stash 给后续阶段,导致真实 dev 初始化可能跑到 Jenkins controller / build 节点;脚本还安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖,超出了服务器初始化职责。
- 决策Server-Provision 只做服务器初始化,全程运行在目标部署 agentdevelopment 使用 `linux && genarrative-dev-deploy`release 使用 `linux && genarrative-release-deploy``Prepare Provision Tools``Provision Server` 在同一个目标 agent workspace 顺序执行,不再切到 `linux && genarrative-build`,不再 `stash/unstash` 工具包。`scripts/jenkins-server-provision.sh` 不再安装 clang / lld / pkg-config / libssl-dev / sccache非 dry-run 仍要求目标 dev / release agent 具备 root 权限,因为 provision 会写 systemd、Nginx、`/etc` 和系统用户。Job 的 `Pipeline script from SCM` 与 Jenkinsfile 参数 `SOURCE_GIT_REMOTE_URL` 都必须使用本机路径或目标 agent 可访问的内网 Git 源,不允许公网 Git fallback。
- 决策Server-Provision 只做服务器初始化,全程运行在目标部署 agentdevelopment 使用 `linux && genarrative-dev-deploy`release 使用 `linux && genarrative-release-deploy``Prepare Provision Tools``Provision Server` 在同一个目标 agent workspace 顺序执行,不再切到 `linux && genarrative-build`,不再 `stash/unstash` 工具包。`scripts/jenkins-server-provision.sh` 不再安装 clang / lld / pkg-config / libssl-dev / sccache当前 OpenSSL 3.2 独立运行时自举会安装 `build-essential` 等最小工具,这是满足 api-server/libcurl 运行时符号的受控例外,不代表 provision 承担 api-server 构建职责。非 dry-run 仍要求目标 dev / release agent 具备 root 权限,因为 provision 会写 systemd、Nginx、`/etc` 和系统用户。Job 的 `Pipeline script from SCM` 与 Jenkinsfile 参数 `SOURCE_GIT_REMOTE_URL` 都必须使用本机路径或目标 agent 可访问的内网 Git 源,不允许公网 Git fallback。
- 追加决策2026-06-10`Prepare Provision Tools` 必须先读取目标机现状,再准备需要的文件。目标机 `/usr/local/bin/otelcol-contrib` 版本匹配 `OTELCOL_VERSION` 时直接复用;`${SPACETIME_ROOT}/bin/current/spacetimedb-cli``spacetimedb-standalone` 存在且 CLI 版本匹配 `SPACETIME_EXPECTED_VERSION``SPACETIME_DOWNLOAD_ROOT` 中的版本时,直接复用当前安装生成 `provision-tools/`。只有目标机缺失、不可执行或版本不匹配时,才消费 `PROVISION_DOWNLOADS_DIR` 中的本地包或进入下载分支。
- 影响范围:`jenkins/Jenkinsfile.production-server-provision``scripts/jenkins-server-provision.sh`、生产运维文档、Server-Provision 排障口径。
- 验证方式Jenkins 日志中 Server-Provision 的 `Prepare``Checkout Provision Files``Prepare Provision Tools``Provision Server` 都在目标 dev / release agent 上执行;日志不出现 `Running on Jenkins``linux && genarrative-build``stash 'server-provision-tools'``Git 主地址拉取失败...改用备用地址``https://git.genarrative.world/GenarrativeAI/Genarrative.git` 或构建依赖 / sccache 安装步骤;`bash -n scripts/jenkins-server-provision.sh` 和编码检查通过。
@@ -572,6 +611,24 @@
- 验证方式:关闭任一创作入口后,新建创作请求返回 `creation_entry_disabled`;公开作品列表 / 详情 / 启动 / 运行态动作不返回该错误进入平台首页不弹“平台首页creation_entry_disabled”关闭态入口卡显示锁定状态且不显示 `10-20泥点数`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-03 外部内容生成改为持久队列加 worker 角色
- 背景:拼图首图、图集、音频等外部生成链路长期占用 `api-server` HTTP handler导致扩容只能放大 API 进程,且 HTTP 超时和外部 provider 波动会直接影响创作入口。
- 决策:外部生成任务统一进入 SpacetimeDB `external_generation_job` 持久队列,由 `api-server``external-generation-worker` 进程角色 claim lease 后执行HTTP 角色只做鉴权、表单/状态初始化、入队和返回 `queued/running/completed/failed` 操作状态。生产通过 systemd worker 模板增加实例数或提高 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩容,`GENARRATIVE_PROCESS_ROLE=all` 仅用于本地 smoke。拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images``generate_puzzle_ui_background` 已接入 worker业务写回必须在 SpacetimeDB transaction 内校验 `external_generation_job``job_id + worker_id + lease_token`、job kind、owner 和 source entity其中首图 worker 的前置 `compile_puzzle_agent_draft` 也必须带 guard。worker 核心业务写回失败不能返回内存快照并把 job 标成 completed失败态业务写回成功后才能把 job 标成 failed失败态未写回则保留租约等待后续重领。拼图业务失败不自动重试只保留 lease 过期后的崩溃重领,避免钱包扣退费幂等漂移。生产发布会启用默认 `genarrative-external-generation-worker@1.service` 并等待 worker activeworker 停机时停止 claim 新任务并 drain 当前任务。
- 2026-06-07 追加:`GENARRATIVE_EXTERNAL_GENERATION_MODE` 使用 `queue|inline` 显式策略;生产和容器扩缩容验证保持 `queue`。本地开发若需要同步等待结果,应通过 `.env.local` 或本机环境显式配置为 `inline`,由 HTTP handler 复用同一 worker executor 直接返回 `completed`,不创建 `external_generation_job`,不支持 worker 动态扩缩容;脚本不得硬编码该策略。拼图写回 guard 字段改为可选queue 路径仍必须完整校验 `job_id + worker_id + lease_token`inline 路径只允许三项同时为空,半空 guard 仍拒绝。
- 2026-06-11 追加:生产新增固定 `external-generation-controller` 进程角色和 `genarrative-external-generation-controller.service`。controller 只读取 `get_external_generation_queue_stats_and_return` 队列统计并管理 `genarrative-external-generation-worker@N.service`,不监听 HTTP、不执行外部生成任务默认保留 `@1`,按 `claimable_pending + running_active + expired_running` 计算目标实例数,上限由 `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MAX_WORKERS` 控制,缩容需要连续空闲轮数且每轮只停最高编号一个实例。
- 影响范围:`server-rs/crates/spacetime-module/src/external_generation.rs``server-rs/crates/spacetime-client/src/external_generation.rs``server-rs/crates/api-server/src/external_generation_worker.rs``server-rs/crates/api-server/src/external_generation_worker_controller.rs``deploy/systemd/genarrative-external-generation-worker@.service``deploy/systemd/genarrative-external-generation-controller.service``deploy/env/external-generation-controller.env.example``scripts/deploy/production-api-deploy.sh``scripts/jenkins-server-provision.sh`、拼图 `compile_puzzle_draft`、拼图 `generate_puzzle_images`、拼图 `generate_puzzle_ui_background`、生产 env 模板和运维文档。
- 验证方式:`npm run spacetime:generate``npm run check:spacetime-schema``npm run check:server-rs-ddd``cargo check -p api-server --manifest-path server-rs/Cargo.toml`,并在 queue 模式下用 `GENARRATIVE_PROCESS_ROLE=all npm run dev` smoke 至少一次 queued -> worker 完成链路;本地 inline 排查只确认不创建 `external_generation_job`
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-06-03 外部生成 worker lease 使用 SpacetimeDB 时间和 token 栅栏
- 背景:外部生成 worker 支持多进程动态缩扩容后,长任务超过单次 lease、worker 本机时钟漂移或复用 worker id 都可能导致同一任务被重复领取并被过期执行者回写。
- 决策:`external_generation_job` 新增末尾字段 `lease_token``claim` 使用 SpacetimeDB `ctx.timestamp` 计算 lease生成本次 claim tokenworker 执行期间调用 `renew_external_generation_job_lease_and_return` 续租;`complete/fail` 必须带 `worker_id + lease_token` 才能回写。拼图 `compile_puzzle_draft` 的 dedupe key 包含本次 `extgen-` job id避免同一 session 的失败或完成 job 吞掉后续重新生成。拼图首图前置 `compile_puzzle_agent_draft`、图片保存、UI 背景与失败态业务写回同样必须携带 lease guard并在 `compile_puzzle_agent_draft``save_puzzle_generated_images``save_puzzle_ui_background``mark_puzzle_draft_generation_failed``mark_puzzle_level_generation_failed` 的 SpacetimeDB 事务内校验。
- 影响范围:`server-rs/crates/spacetime-module/src/external_generation.rs``server-rs/crates/spacetime-module/src/puzzle.rs``server-rs/crates/module-puzzle/src/commands.rs``server-rs/crates/spacetime-client/src/external_generation.rs``server-rs/crates/spacetime-client/src/puzzle.rs``server-rs/crates/api-server/src/external_generation_worker.rs``server-rs/crates/api-server/src/puzzle/handlers.rs``server-rs/crates/api-server/src/puzzle/draft.rs``server-rs/crates/api-server/src/puzzle/generation.rs`
- 验证方式:`npm run spacetime:generate``npm run check:spacetime-schema``cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml``cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml``GENARRATIVE_PROCESS_ROLE=all npm run dev` 后检查 `/healthz`
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-06-04 Draft Generation Shelf 剩余草稿打开 intent 收口
- 背景:拼图 / 抓大鹅草稿打开 intent 已归入 `platformDraftGenerationShelfModel.ts`,但方洞挑战、大鱼吃小鱼和视觉小说仍在平台壳层内联判断已发布详情、缺 session、active generating、当前结果页和普通草稿恢复。
@@ -945,7 +1002,7 @@
- 决策:平台壳在生成失败时必须同时标记草稿 notice 和 pending 作品架条目为 `failed`,不得删除 pending 条目。失败 notice 要保存错误消息并在用户离开生成页后触发带来源的 `PlatformErrorDialog`;作品架本地失败 notice 要覆盖持久化生成中摘要,失败草稿仍显示为草稿卡但不显示“生成中”。点击失败草稿必须优先恢复失败 / 重试页,不能按持久化 `generating` 重新启动生成;拼图契约已允许 `generationStatus=failed`pending 拼图和后端失败回写都按 session 独立落失败态,跳一跳 / 木鱼 / 抓大鹅等也直接映射为 `failed` 或对应失败态。
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/custom-world-home/creationWorkShelf.ts``src/components/custom-world-home/CustomWorldCreationHub.tsx`、玩法链路文档和失败态交互测试。
- 验证方式:`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle|background match3d"`;失败后返回草稿 Tab 应看到对应新增草稿,且没有“生成中”标记;后台失败应弹出错误来源,点击失败草稿应进入失败 / 重试页。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``.hermes/shared-memory/pitfalls.md`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/project-memory/shared-memory/pitfalls.md`
## 2026-05-23 所有玩法生成页统一圆环主视觉
@@ -1098,7 +1155,7 @@
- 追加决策:`Genarrative-Stdb-Module-Build` 的 Checkout 逻辑应复用 Jenkins GitSCM 已完成的工作区状态。`COMMIT_HASH` 为空或已与当前 `HEAD` 一致时,不再额外执行 `git clean` / `git checkout`;只有需要切到指定且不同的 commit 时才补 fetch、校验和切换避免在 Windows workspace 里二次清理触发权限拒绝。
- 影响范围:`jenkins/Jenkinsfile.production-stdb-module-build` 及后续所有同类 Windows 构建流水线。
- 验证方式Jenkins 日志中应能看到 `[jenkins-powershell] user:``[jenkins-powershell] exe:`Checkout 阶段会打印当前 `HEAD` 与请求 commit并在 `COMMIT_HASH` 为空或一致时直接继续;不再停在 `PipelineNodeTreeScanner... Cannot run program "powershell"` 或重复 `git clean` 的退出码 5。
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``.hermes/shared-memory/pitfalls.md`
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``docs/project-memory/shared-memory/pitfalls.md`
## 2026-05-19 tracking outbox 改为 rotate 后异步 flush
@@ -1128,7 +1185,7 @@
- 背景Windows 本机直连极高 VU 压测会放大本地连接与发送缓冲行为,和线上 Linux + Nginx + systemd 拓扑不一致;需要一个更接近生产网络层的模拟方案,但不能扰动当前生产发布链路。
- 决策:新增 `deploy/container/` 容器化方案,使用 Docker Compose 组合 Linux release `api-server`、容器 SpacetimeDB、容器 Nginx、`otelcol-contrib` debug exporter 和可选 k6。该方案只用于本机或预发压测模拟不替换当前生产 `systemd + Nginx + Jenkins` 路径。
- 服务器模拟参数2026-05-18 通过 `ssh genarrative-release` 采样,目标机器为 2 vCPU / 约 2 GiB RAM / Ubuntu 24.04 / Nginx `worker_connections=768`;容器方案按待发布运行口径使用 `nofile=4096`,并在 compose 中限制 `spacetimedb cpus=1.0 mem_limit=768m``api-server cpus=2.0 mem_limit=1g``nginx cpus=0.25 mem_limit=128m``otelcol cpus=0.25 mem_limit=128m``k6 cpus=0.5 mem_limit=512m`Collector 镜像默认使用 `otel/opentelemetry-collector-contrib:0.151.0`
- 服务器模拟参数2026-05-18 通过 `ssh genarrative-release` 采样,目标机器为 2 vCPU / 约 2 GiB RAM / Ubuntu 24.04 / Nginx `worker_connections=768`;容器方案按待发布运行口径使用 `nofile=4096`,并在 compose 中限制 `spacetimedb cpus=1.0 mem_limit=896m``api-server cpus=2.0 mem_limit=1g``external-generation-worker cpus=2.0 mem_limit=1g``nginx cpus=0.5 mem_limit=128m``otelcol cpus=0.25 mem_limit=128m``k6 cpus=1.0 mem_limit=512m`Collector 镜像默认使用 `otel/opentelemetry-collector-contrib:0.151.0`
- 隔离边界:容器方案使用独立 `deploy/container/api-server.env`、独立 Nginx 配置、独立 compose 命令和默认 `18080` 端口;真实 token 不进入镜像、不提交 Git生产 systemd 单元、Jenkins 发布脚本和 `deploy/nginx/` 模板仍是正式线上来源。
- 生产 Collectorserver-provision 可安装 `otelcol-contrib.service` 和本机 debug exporter 配置,但二进制由 Jenkins 构建机先准备 `provision-tools/otelcol-contrib` 再上传到 release 部署 agent目标机不从 GitHub 下载api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制。
- 影响范围:`deploy/container/``scripts/container-compose.mjs``package.json` 容器命令、开发运维文档和容器 build context 排除规则。
@@ -1200,7 +1257,7 @@
- 决策:发布正式世界时,`spacetime-module` 不再把 `session.seed_text` 当作唯一 `setting_text` 兜底,而是调用 `module-custom-world::resolve_custom_world_publish_setting_text(...)` 从 payload、当前草稿 profile 和 seed 依次派生。
- 影响范围RPG / custom-world agent 发布链路、`custom_world_profile` 编译入库、公开 gallery 投影。
- 验证方式:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml``cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`;本地 api-server 重启后检查 `/healthz`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``.hermes/shared-memory/pitfalls.md`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/project-memory/shared-memory/pitfalls.md`
## 2026-05-19 系列素材 n\*n 图集抽为 api-server 通用模块
@@ -1548,7 +1605,7 @@
- 背景:项目已经全面移除 Maincloud 运行口径,但历史脚本、测试名和文档仍可能让后续开发误用 `api-server:maincloud``GENARRATIVE_SPACETIME_MAINCLOUD_*`
- 决策:`maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求全部视为历史残留,后续禁止新增、运行或引用;后端 API smoke 统一使用 `npm run dev:api-server` 并检查 `/healthz`
- 影响范围:`AGENTS.md``docs/technical/``.hermes/shared-memory/`、后端启动脚本、测试支撑和所有后续工程文档。
- 影响范围:`AGENTS.md``docs/technical/``docs/project-memory/shared-memory/`、后端启动脚本、测试支撑和所有后续工程文档。
- 验证方式:新增或修改后端相关文档时,检查不得要求 `api-server:maincloud``GENARRATIVE_SPACETIME_MAINCLOUD_*`;触碰历史残留时同步删除或改名。
- 关联文档:`docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md``docs/technical/SPACETIMEDB_CLOUD_CONFIG_REMOVAL_2026-05-02.md`
@@ -1611,7 +1668,7 @@
## 2026-05-07 视觉小说 VN-11 负向扫描门禁
- 背景:视觉小说 TXT 模板进入收口后,需要一个可重复执行的守门方式,避免工程代码误入回放能力或外部平台功能。
- 决策:新增 `npm run check:visual-novel-vn11`,由 `scripts/check-visual-novel-vn11-negative-scan.mjs` 扫描 `src/``packages/shared/src/``server-rs/crates/``docs/``.hermes/shared-memory/`;工程代码中不允许出现 replay / 回放 / 录制 / 复盘类直出命中;外部平台能力误入只在视觉小说实现路径内检查,避免把平台已有账号、会员、后台等能力误判为视觉小说迁入。
- 决策:新增 `npm run check:visual-novel-vn11`,由 `scripts/check-visual-novel-vn11-negative-scan.mjs` 扫描 `src/``packages/shared/src/``server-rs/crates/``docs/``docs/project-memory/shared-memory/`;工程代码中不允许出现 replay / 回放 / 录制 / 复盘类直出命中;外部平台能力误入只在视觉小说实现路径内检查,避免把平台已有账号、会员、后台等能力误判为视觉小说迁入。
- 影响范围:视觉小说 VN-11 验收、后续 `visual-novel` 增量改动、同类新玩法负向扫描脚本。
- 验证方式:执行 `npm run check:visual-novel-vn11`,报告写入 `docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`;当前扫描结论为工程代码无回放类直出命中,视觉小说实现路径无外部平台能力误入。
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md``docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`
@@ -1628,7 +1685,7 @@
- 背景:视觉小说模板主链已经落地完成,需要把 PRD、表目录、prompt 工具说明、负向扫描报告和维护经验收成新开发者可直接接手的一组文档,避免后续仍回头查旧 TXT 迁移方案。
- 决策:视觉小说后续维护的正式入口固定为 `AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md``SPACETIMEDB_TABLE_CATALOG.md``VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md``VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md``VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md``VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`;旧 TXT 迁移文档仅保留历史参考地位。
- 影响范围:视觉小说 PRD 收口、技术文档索引、经验文档索引、Hermes 共享记忆和后续维护阅读顺序。
- 影响范围:视觉小说 PRD 收口、技术文档索引、经验文档索引、项目共享记忆和后续维护阅读顺序。
- 验证方式打开上述文档即可获得当前实现边界、表目录、Prompt 口径、负向扫描和维护经验;后续维护不需要把旧 TXT 平台工程文档重新当作实现目标。
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md``docs/technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md``docs/experience/VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md`
@@ -1732,9 +1789,9 @@
- 背景:团队有 3 名开发人员,均在各自本地安装 Hermes并需要独立拉取仓库、修改代码、本地测试团队希望形成共享的长期项目记忆。
- 决策:不共享个人 `~/.hermes`,先在 Genarrative 仓库内使用 `.hermes/` 保存可 Git 同步的团队共享记忆、计划和未来 skills。
- 影响范围:`AGENTS.md``.hermes/README.md``.hermes/shared-memory/`
- 验证方式:任一开发者拉取仓库后,在项目根目录启动 Hermes均可读取同一套 `.hermes/shared-memory/` 文件。
- 关联文档:`.hermes/README.md``.hermes/shared-memory/team-conventions.md`
- 影响范围:`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`
## 2026-04-25 后端唯一落地口径固定为 Rust / SpacetimeDB

View File

@@ -1,16 +1,16 @@
# 开发工作流
> 用途:给本地 Hermes 和开发人员提供统一的开发、测试、提交流程。具体命令以 `package.json``server-rs/Cargo.toml``AGENTS.md` 和相关 `docs/` 最新文档为准。
> 用途:给本地 Agent 和开发人员提供统一的开发、测试、提交流程。具体命令以 `package.json``server-rs/Cargo.toml``AGENTS.md` 和相关 `docs/` 最新文档为准。
## 标准任务流程
```text
同步代码 → 读取 AGENTS.md → 读取 .hermes/shared-memory → 查找/完善 docs → 制定计划 → 小步实现 → 本地验证 → 更新文档/记忆 → 提交
同步代码 → 读取 AGENTS.md → 读取 docs/project-memory/shared-memory → 查找/完善 docs → 制定计划 → 小步实现 → 本地验证 → 更新文档/记忆 → 提交
```
## 建议启动方式
在项目根目录启动 Hermes
在项目根目录启动本地 Agent
```bash
cd /path/to/Genarrative
@@ -30,7 +30,7 @@ hermes
- [ ] 当前分支是否正确
- [ ] 是否已拉取最新代码
- [ ] 是否阅读 `AGENTS.md`
- [ ] 是否阅读 `.hermes/shared-memory/` 相关文件
- [ ] 是否阅读 `docs/project-memory/shared-memory/` 相关文件
- [ ] 是否阅读 `README.md` 中的运行和检查命令
- [ ] 是否阅读 `docs/README.md` 及任务相关分类 README
- [ ] 是否存在足够具体的 PRD / 设计 / 技术文档
@@ -52,6 +52,8 @@ npm run dev
Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口,四个 dev 服务依次使用 `start``start + 3`。可用 `GENARRATIVE_DEV_PORT_RANGE``npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效Windows 仍沿用原有端口探测与漂移逻辑。
本地 `npm run dev``npm run dev:spacetime``npm run dev:api-server` 会在 Rust 子进程环境中绕过项目默认 `sccache` wrapper避免损坏的本机 cache daemon 阻断 `spacetime publish``api-server` 启动;显式设置的非 sccache 自定义 wrapper 会被保留。生产 / Jenkins 构建仍按流水线自身的 sccache 策略执行。
该命令会启动:
- SpacetimeDB standalone
@@ -118,6 +120,16 @@ npm run server-manager:panel
npm run dev:spacetime:logs
```
本机隔离验证外部生成 worker 队列、API-only 更新和 worker 动态扩缩容时,优先使用:
```bash
npm run container:worker-smoke -- smoke
```
该命令生成 `deploy/container/worker-smoke/` 下的 gitignored env 与端口 state启动独立 compose project 和独立 SpacetimeDB用 unsupported job 验证 worker claim / fail 回写;排查时用 `api-update` 确认 API 重建不触碰 worker`scale <n>` 调整 worker 数量。
`external_generation_job` 是 private tableworker-smoke 通过 worker 日志里的 job_id 和 unsupported 记录确认消费,不通过 CLI SQL 查询队列表。
worker-smoke 默认把本机 `spacetime` CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 依赖官方大镜像下载。若容器内 Cargo 下载依赖不稳定,追加 `--local-binary`,让容器内 Cargo 复用本机 Cargo 缓存构建当前 `api-server` 二进制,并把产物放进 Debian bookworm smoke runtime可用 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE` 覆盖运行时基础镜像;隔离端口或库数据需要重建时追加 `--force`
后台管理前端:
```bash
@@ -149,6 +161,15 @@ Codex 项目级 hook 保存在 `.codex/config.toml` 与 `.codex/hooks/`
个人 token、模型路由、MCP server 仍属于个人环境;需要时由成员本机执行 `codegraph install` 或查看 `codegraph install --print-config codex`,不要提交个人全局配置。
Agent 本地 RAG 文档索引:
```bash
npm run rag:index
npm run rag:search -- --query "搜索内容"
```
RAG 主要供 Agent 检索项目上下文,开发者仍按 `AGENTS.md``docs/README.md``docs/project-memory/` 阅读正式文档。RAG 仅索引项目文档和项目共享记忆,默认不把 LanceDB、Transformers.js 或本地 embedding 模型装入根 `package.json`。需要启用 RAG 时Agent 必须先询问用户是否安装本地运行时依赖;用户确认后只安装到 gitignored 的 `.rag/runtime/`,模型缓存和向量库也留在 `.rag/`。具体命令见 `scripts/rag/README.md`
## 常用检查命令
- 后端通用用户行为埋点统一通过 `record_tracking_event_and_return` procedure、`SpacetimeRuntimeClient::record_tracking_event(...)` 与 api-server `tracking` 中间件写入 `tracking_event` / `tracking_daily_stat`后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 默认排除;作品级游玩埋点统一使用 `work_play_start`,详细事件清单见 `docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md`
@@ -264,17 +285,17 @@ npm run check:server-rs-ddd
- 工程修改要同步更新对应文档。
- 如果没有现成文档,新文档统一放入 `docs/` 下合适分类。
- `.hermes/shared-memory/` 只记录高频、长期、团队共享的摘要和索引,不替代完整 PRD/技术文档。
- 如果 `.hermes/shared-memory/` 与代码或 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正共享记忆。
- `docs/project-memory/shared-memory/` 只记录高频、长期、团队共享的摘要和索引,不替代完整 PRD/技术文档。
- 如果 `docs/project-memory/shared-memory/` 与代码或 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正共享记忆。
## 提交前建议让 Hermes 执行
## 提交前建议让 Agent 执行
涉及拼图、抓大鹅、敲木鱼统一创作 / 生成链路、Phase 2 之后的跨玩法回归或本地 dev 栈时,先按 `quality-gates/README.md``quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md` 和对应单项门禁文档执行自动脚本与体验检查。
```text
请检查当前 git diff指出
1. 是否违反 AGENTS.md 或 .hermes/shared-memory 约定;
1. 是否违反 AGENTS.md 或 docs/project-memory/shared-memory 约定;
2. 是否需要补充 docs
3. 是否有长期知识需要写入 .hermes/shared-memory
3. 是否有长期知识需要写入 docs/project-memory/shared-memory
4. 建议的测试命令和提交信息。
```

View File

@@ -6,7 +6,7 @@
| 场景 | 优先阅读 |
| --- | --- |
| 建立项目背景 | `README.md``AGENTS.md``.hermes/shared-memory/project-overview.md` |
| 建立项目背景 | `README.md``AGENTS.md``docs/project-memory/shared-memory/project-overview.md` |
| 找当前文档 | `docs/README.md` |
| 产品、命名、UI、协作和废弃路线 | `docs/【项目基线】当前产品与工程约束-2026-05-15.md` |
| 后端、DDD、API、SpacetimeDB schema 和表目录 | `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` |
@@ -21,7 +21,7 @@
通用复杂任务:
1. `AGENTS.md`
2. `.hermes/shared-memory/`
2. `docs/project-memory/shared-memory/`
3. `docs/README.md`
4. 与任务匹配的当前融合文档
@@ -50,5 +50,5 @@
- 新增工程实现时,如果已有对应当前文档,必须同步更新。
- 如果没有合适位置,新文档文件名必须使用 `【标签名】中文标题-YYYY-MM-DD.md`
- 阶段性流水账、一次性修复记录和已关闭实验不要再新增为长期文档。
- 阶段性计划和一次性 TODO 不再作为长期文档目录;需要保留的决策、流程和坑点应进入 `docs/` 当前文档或 `.hermes/shared-memory/`
- 阶段性计划和一次性 TODO 不再作为长期文档目录;需要保留的决策、流程和坑点应进入 `docs/` 当前文档或 `docs/project-memory/shared-memory/`
- 如果文档与代码冲突,先确认代码事实,再更新过期文档和共享记忆。

View File

@@ -50,4 +50,4 @@
## 是否需要更新团队记忆
- [ ] 不需要
- [ ] 需要,建议更新:`.hermes/shared-memory/...`
- [ ] 需要,建议更新:`docs/project-memory/shared-memory/...`

View File

@@ -15,6 +15,38 @@
- 关联:相关文件、文档、提交或 Issue
```
## 外部生成 worker 业务失败重试会撞上钱包扣退费幂等
- 现象:同一个外部生成 job 如果第一次业务失败后退款,再用同一个业务资源 ID 自动重试并成功,钱包 `consume` ledger 可能因为同 ID 已存在而跳过,最终出现“失败已退、成功不再扣”的余额漂移。
- 原因:资产操作扣费和退款都用稳定 ledger id 做幂等;这能保护 lease 过期后的崩溃重领不重复扣费,但不适合“已明确失败且已退款”的自动业务重试。
- 处理:拼图 `puzzle_compile_draft` 首期设置 `max_attempts=1`,业务失败直接 failed只保留 running lease 过期后的崩溃重领。后续若要恢复自动 retry必须先引入 attempt-aware billing 或可配对撤销的账本接口。
- 验证:检查 `external_generation_job.max_attempts`、worker 失败回写和钱包 ledger失败后草稿进入 failed重试应由用户重新触发新任务而不是旧 job 自动 pending。
- 关联:`server-rs/crates/api-server/src/puzzle/handlers.rs``server-rs/crates/api-server/src/asset_billing.rs``server-rs/crates/spacetime-module/src/runtime/profile.rs``docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`
## 外部生成队列不再由 HTTP 进程兜底执行
- 现象:拼图首关生成接口返回 `queued`,但生成页长时间不完成,重启 `genarrative-api.service` 也没有推进任务。
- 原因HTTP 角色只入队,不再直接调用外部 provider如果没有运行 `GENARRATIVE_PROCESS_ROLE=external-generation-worker``all` 的进程,`external_generation_job` 会停留在 `pending/running`,直到有 worker claim。
- 处理:生产用 `systemctl enable --now genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service` 启动保底 worker 和 controller首次 API deploy 会在默认 worker pattern 下自动启用并启动 `@1`、等待 worker active并重启验活 controller。扩容默认交给 controller 按队列统计启动 `@2.service` 等实例手动扩缩容只作为兜底worker 收到停机信号后会停止 claim 新任务并等待当前任务完成。本地 smoke 可临时用 `GENARRATIVE_PROCESS_ROLE=all npm run dev`;本地若只想同步排查可通过 `.env.local` 或本机环境设置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline`,但这不会创建 job也不能验证 worker 扩缩容。
- 验证:`systemctl status genarrative-external-generation-controller.service 'genarrative-external-generation-worker@*.service'` 能看到 controller 和 worker 实例queue 模式下任务被 claim 后 `worker_id``lease_expires_at` 会更新,完成后 session 进入 ready 或 failedinline 模式下不应产生新的 `external_generation_job`
- 关联:`deploy/systemd/genarrative-external-generation-worker@.service``deploy/systemd/genarrative-external-generation-controller.service``deploy/env/external-generation-controller.env.example``server-rs/crates/spacetime-module/src/external_generation.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 外部生成 worker 业务写回必须同事务校验 lease guard
- 现象worker `complete/fail` 已校验 `worker_id + lease_token`,但如果玩法 session / work profile 写回在此之前单独调用,过期 worker 仍可能先写入业务状态,随后才在 job complete/fail 阶段失败;带计费包装的旧 worker 还可能因为 stale guard 错误触发补偿退款。
- 原因:队列状态栅栏只保护 `external_generation_job` 自身,不会自动保护玩法 procedure。业务写回必须自己带 claim 后的 `job_id / worker_id / lease_token`,并在同一个 SpacetimeDB transaction 内校验 job 仍为 `running`、lease 未过期、job kind、owner 和 source entity 匹配。
- 处理:拼图首图 worker 的前置 `compile_puzzle_agent_draft``save_puzzle_generated_images``save_puzzle_ui_background``mark_puzzle_draft_generation_failed``mark_puzzle_level_generation_failed` 已接入 `external_generation_job` lease guardapi-server 的资产扣费包装遇到这类 stale worker lease guard 错误时不执行补偿退款,错误文本包含 `external_generation_job 当前不是 running 状态``external_generation_job 不存在` 时也按 stale guard 处理。inline 模式只允许 `job_id / worker_id / lease_token` 三项同时为空,半空 guard 仍拒绝。后续迁移其它玩法 worker 时必须复用该模式,不能只在 worker 进程内保存一份 token。
- 验证:`cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml``cargo test -p api-server asset_operation_billing_does_not_refund_stale_worker_lease_errors --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/spacetime-module/src/external_generation.rs``server-rs/crates/spacetime-module/src/puzzle.rs``server-rs/crates/api-server/src/external_generation_worker.rs``server-rs/crates/api-server/src/asset_billing.rs``docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`
## 外部生成 worker 核心业务写回失败不能完成 job
- 现象worker 已经生成图片并拿到本地合成 session 快照,但 SpacetimeDB 业务写回因连接、旧 wasm 或 lease guard 失败没有真实落库;如果此时仍把 `external_generation_job` 标成 `completed`,前端只会看到队列完成而 session 长时间不变化,后续也没有 worker 会重领修复。
- 原因:同步 HTTP handler 的“外部 provider 已成功但 SpacetimeDB 短暂不可用时返回内存快照”降级语义,不能直接搬进异步 worker。worker 的完成状态必须代表核心业务事实已经持久化。
- 处理worker 路径的 `save_puzzle_generated_images` / `save_puzzle_ui_background` 等核心业务写回失败时直接返回错误;只有核心写回已经成功后的非关键投影回写才允许降级记录 warning。业务失败态也必须先写回 session / work profile写回成功后才允许把队列 job 标为 failed失败态未写回时保留租约等待 lease 过期后重领。生产首装和首次 API deploy 都必须至少启用一个 worker 实例,例如 `systemctl enable --now genarrative-external-generation-worker@1.service`
- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml``cargo test -p api-server asset_operation_billing_does_not_refund_stale_worker_lease_errors --manifest-path server-rs/Cargo.toml`,并在 smoke 时确认 queued 任务被 worker 消费后 session 真实更新。
- 关联:`server-rs/crates/api-server/src/puzzle/draft.rs``server-rs/crates/api-server/src/puzzle/generation.rs``server-rs/crates/api-server/src/external_generation_worker.rs``docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`
## 生产冷备份后 API 不能只依赖 SpacetimeDB 自恢复
- 现象release 机器 `03:20` 冷备份后,`spacetimedb.service` 已恢复,但作品列表、创作入口配置或公开 gallery 继续超时 / 502 / 504`genarrative-api.service` 保持 stopped。
@@ -71,6 +103,14 @@
- 验证:`cargo test -p spacetime-module custom_world_public_interactions_accept_legacy_missing_published_at --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/spacetime-module/src/custom_world.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`
## 拼图公开推荐不要只按 Published 判断
- 现象:后台把拼图作品隐藏后,作品不在公开列表里显示,但玩家通关其它拼图后的推荐下一作品仍可能出现这条隐藏作品。
- 原因:拼图隐藏只把 `puzzle_work_profile.visible` 置为 `false`,不会把 `publication_status``Published` 改走;通关推荐候选曾只通过 `by_puzzle_work_publication_status().filter(Published)` 取数,漏掉可见性判断。
- 处理:拼图公开消费路径统一使用 `Published + visible=true`,范围包括 `puzzle_gallery_view``puzzle_gallery_card_view`、兼容 gallery/detail procedure、公开点赞 / Remix、正式公开 runtime 启动和通关后的 `recommended_next_works` 候选。
- 验证:`cargo test -p spacetime-module hidden_published_puzzle_work_is_not_public_visible_candidate --manifest-path server-rs/Cargo.toml`,并在需要时用后台隐藏一个已发布拼图后重试通关推荐。
- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 推荐页 WF 点赞不要落到 RPG / custom-world
- 现象:推荐页里给 `WF-*` 敲木鱼作品点赞时,平台错误弹窗显示 `custom_world 已发布作品不存在,无法点赞`
@@ -99,8 +139,8 @@
## “我的”页每日任务卡不要硬编码进度,也不要跨日保留旧状态
- 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。
- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗;后来虽然后端按北京时间 0 点切换业务日,但前端停留在“我的”页时不会跨日刷新,可能继续展示上一日已领取状态。
- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心;停留在“我的”页跨过北京时间 0 点时,先非阻断 refresh 登录态写入新业务日 `daily_login`,再重拉任务中心。
- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗;后来虽然后端按北京时间 0 点切换业务日,但前端停留在“我的”页时不会跨日刷新,可能继续展示上一日已领取状态。若认证成功后把 `daily_login` 当普通埋点写入,或历史 `profile_task_config` 仍保留旧 `profile.login.daily` 事件键,新业务日也可能写了登录事件却查不到任务进度。
- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心;停留在“我的”页跨过北京时间 0 点时,先非阻断 refresh 登录态写入新业务日 `daily_login`,再重拉任务中心。后端认证成功统一走 `SpacetimeClient::record_daily_login_tracking_event(...)` 与 SpacetimeDB 专用 `record_daily_login_tracking_event_and_return`,默认每日登录任务读取时会把结算字段自愈到 canonical `daily_login`
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应覆盖卡片从后端任务摘要显示 `1 / 1`、领取后显示已完成,以及北京时间 0 点自动 refresh 后重拉任务中心。
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``docs/【项目基线】当前产品与工程约束-2026-05-15.md`
@@ -208,6 +248,22 @@
- 验证:浏览器触发 `/creation/puzzle``/creation/match3d` 的泥点确认弹窗,检查 overlay 最近主题 class 存在、`--platform-modal-fill` 有值且面板为实底;聚焦测试覆盖默认 overlay / panel class。
- 关联:`src/components/common/PlatformMudPointConfirmDialog.tsx``src/components/common/PlatformStatusDialog.tsx``src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx``src/components/unified-creation/workspaces/Match3DCreationWorkspace.tsx`
## 拼图结果页关卡图不要裁切,嵌套图片预览要高于详情弹窗
- 现象:拼图结果页“拼图关卡”列表里的关卡图底部被裁掉;进入关卡详情后点击画面图,看起来没有打开全屏预览。
- 原因:关卡列表复用 `PlatformMediaFrame aspect="standard"` 默认 `object-cover`,方图或竖向生成图会在 4:3 框内被裁切;关卡详情弹窗自身层级高于 `CreativeImageInputPanel` 默认图片预览层级,预览实际打开但被压在详情弹窗后面。
- 处理:结果页关卡缩略图显式传 `imageClassName="h-full w-full object-contain"` 保留完整画面;`CreativeImageInputPanel` 提供 `mainImagePreviewZIndexClassName`,嵌套在高层级弹窗内时由调用方传更高层级。
- 验证:聚焦测试断言关卡缩略图使用 `object-contain` 且没有 `object-cover`,并断言关卡详情内主图预览 overlay 层级高于详情弹窗;浏览器里检查列表完整显示图片,详情内点击画面图能打开可见预览。
- 关联:`src/components/puzzle-result/PuzzleResultView.tsx``src/components/common/CreativeImageInputPanel.tsx``src/components/puzzle-result/PuzzleResultView.test.tsx`
## 图片大图预览不要复用白底工具弹窗
- 现象:点击图像输入面板里的参考图或主图预览后,页面只出现白底非全屏弹窗,背后原页面透出,不能缩放或拖拽查看细节。
- 原因:图片查看和工具弹窗共用了 `UnifiedModal` 白底壳层;该壳层适合编辑 / 选择工具,不适合沉浸式看图,也没有图片边界拖拽状态。
- 处理:纯图片预览统一走 `PlatformImagePreviewModal`,全屏黑底展示,初始 contain 保证完整图片可见,缩放夹在 `1x-4x`,拖拽位移按缩放后的图片边界夹取,避免把图片拖到露出背景。
- 验证:`npm run test -- src/components/common/PlatformImagePreviewModal.test.tsx src/components/common/CreativeImageInputPanel.test.tsx` 应覆盖黑底全屏、缩放上限、拖拽边界和关闭按钮。
- 关联:`src/components/common/PlatformImagePreviewModal.tsx``src/components/common/CreativeImageInputPanel.tsx`
## 玩法入口分类字段缺失要前端兜底
- 现象:平台创作入口初始化时,`platformEntryCreationTypes.ts` 直接对 `creationTypes[].categoryId` / `categoryLabel``trim()`,一旦后端旧数据、局部 mock 或异常返回里缺字段,整个创作页会在 `derivePlatformCreationTypes(...)` 里直接炸掉。
@@ -378,7 +434,7 @@
- 现象:敲木鱼创作时点击“生成”,前端提示 `SpacetimeDB procedure 调用超时`,但服务端日志更早出现 `Failed to BSATN deserialize procedure return value` 或类似反序列化错误。
- 原因:本机 `spacetime` CLI / standalone 版本与 `server-rs/Cargo.toml` 锁定的 `spacetimedb` 版本不一致时procedure 返回值会在宿主侧反序列化失败api-server 继续等待就表现成调用超时。若旧 standalone 进程还在复用,也会把这个错配继续带进新一轮创作。
- 处理:先用 `spacetime --version` 确认 `spacetimedb tool version`,再和 `server-rs/Cargo.toml``spacetimedb = "..."` 对齐;必要时执行 `spacetime version install <version> && spacetime version use <version>`后重启 `npm run dev:spacetime`。当前 dev 脚本会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免继续复用旧宿主。
- 处理:先用 `spacetime --version` 确认 `spacetimedb tool version`,再和 `server-rs/Cargo.toml``spacetimedb = "..."` 对齐;遇到版本不匹配时先直接执行 `spacetime version install <version> && spacetime version use <version>`或在目标就是最新版本时执行 `spacetime version upgrade`,升级后重启 `npm run dev:spacetime` 再重试。当前 dev 脚本会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免继续复用旧宿主。
- 验证:`spacetime --version` 输出与 `server-rs/Cargo.toml` 一致,`http://127.0.0.1:3101/v1/ping` 正常,`npm run test -- scripts/dev.test.ts` 通过,敲木鱼创作点击生成不再卡在 procedure timeout。
- 关联:`scripts/dev.mjs``scripts/dev.test.ts``server-rs/Cargo.toml``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
@@ -497,6 +553,14 @@
- 验证:`tr '\0' '\n' < /proc/$(systemctl show genarrative-api.service -p MainPID --value)/environ | grep GENARRATIVE_TRACKING_OUTBOX_DIR` 应指向 `/var/lib/genarrative/tracking-outbox`;重启后当前 PID 不再出现 `Permission denied (os error 13)`
- 关联:`scripts/deploy/production-api-deploy.sh``scripts/jenkins-server-provision.sh``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## release otelcol 217/USER 和备份 timer inactive 分开处理
- 现象release 巡检中 `otelcol-contrib.service` 持续 `activating (auto-restart)`,日志出现 `status=217/USER` / `Failed to determine user credentials`;同时 `genarrative-database-backup.timer` 显示 `enabled``inactive/dead``NEXT` / `Trigger` 为空。
- 原因otelcol 的 systemd unit 使用 `User=otelcol` / `Group=otelcol`,但目标机缺少该系统用户和 `/etc/otelcol/genarrative-debug.yaml`;备份 timer 在 missed window 后未处于 active waiting 状态,直接重启 Persistent timer 可能在白天立刻补跑冷备份并停止 SpacetimeDB。
- 处理:先创建系统用户 / 组 `otelcol`,补齐 `/var/lib/otelcol``/etc/otelcol/genarrative-debug.yaml``/var/log/genarrative`,再重启 `otelcol-contrib.service`;修 timer 时先 `touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer`,再 `systemctl daemon-reload && systemctl start genarrative-database-backup.timer`,避免当前窗口立即补跑冷备份。
- 验证:`otelcol-contrib.service``active (running)` 且监听 `127.0.0.1:4317/4318``systemctl list-timers genarrative-database-backup.timer --all` 显示下一次触发约为次日 `03:20``/healthz``/readyz``/v1/ping` 仍通过。
- 关联:`scripts/jenkins-server-provision.sh``deploy/systemd/otelcol-contrib.service``deploy/otelcol/genarrative-debug.yaml``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 外部 API 失败没法追溯先查 external_api_call_failure
- 现象VectorEngine 图片生成 / 编辑接口对前端只表现为 `502` / `504` 或“上游服务请求失败”,但难以区分是请求发送失败、上游 429/5xx、响应解析失败、未返回图片还是下载图片失败。
@@ -1288,8 +1352,8 @@
- 现象Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)``sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。
- 原因环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时Cargo 的 `sccache rustc -vV` 可能先超时。
- 处理:保留 `server-rs/.cargo/config.toml``rustc-wrapper = "sccache"`Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro``sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`
- 验证:`rustc -Vv` 能输出版本;冷启动后原始 `cargo check -p api-server``cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明仍在使用 sccache/OSS 缓存Jenkins 日志出现“未找到可用 sccache改用 rustc 直接构建”后仍继续真实构建。
- 处理:保留 `server-rs/.cargo/config.toml``rustc-wrapper = "sccache"`本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server``scripts/dev.mjs` 给 Rust 子进程注入直通 wrapper自动绕过项目默认 sccache避免损坏的 daemon 阻断 `spacetime publish``api-server` 启动;显式设置的非 sccache 自定义 wrapper 会被保留。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro``sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`
- 验证:`rustc -Vv` 能输出版本;本地 `npm run dev` 能完成 `spacetime publish``api-server` `/healthz`、主站 Vite 和后台 Vite 启动;冷启动后原始 `cargo check -p api-server``cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明原始 Cargo/Jenkins 路径仍可使用 sccache/OSS 缓存Jenkins 日志出现“未找到可用 sccache改用 rustc 直接构建”后仍继续真实构建。
- 关联:`scripts/dev.mjs``jenkins/Jenkinsfile.production-stdb-module-build``docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md``docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本
@@ -1948,9 +2012,9 @@
- 现象:`http://127.0.0.1:3001/` 打不开,但 `3000 / 3101 / 8082` 仍有进程;`npm run dev` 直接退出,没有把新栈拉起来。
- 原因:旧 worktree 的 `api-server``spacetime-standalone` 和 Vite 还活着,或者当前 worktree 的本机 SpacetimeDB CLI 默认版本低于仓库锁定版本,`scripts/dev.mjs` 会先校验版本再启动并直接报错退出。
- 处理:先停掉占用端口的旧进程,再执行 `spacetime version list``spacetime version use 2.4.1`,确认本机 CLI/standalone 与仓库一致后重新启动 `npm run dev -- --no-interactive --web-port 3001 --api-port 8083 --spacetime-port 3103 --admin-web-port 3104`
- 处理:先停掉占用端口的旧进程,再执行 `spacetime version list`,确认本机 CLI/standalone 与 `server-rs/Cargo.toml` 锁定版本一致;不一致时先直接升级 / 切换到锁定版本,再重新启动 `npm run dev -- --no-interactive --web-port 3001 --api-port 8083 --spacetime-port 3103 --admin-web-port 3104`
- 验证:`http://127.0.0.1:3001/``http://127.0.0.1:8083/healthz``http://127.0.0.1:3103/v1/ping` 都返回 200且进程命令行指向当前 worktree 路径而不是别的仓库。
- 关联:`scripts/dev.mjs``.hermes/shared-memory/pitfalls.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
- 关联:`scripts/dev.mjs``docs/project-memory/shared-memory/pitfalls.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 微信历史孤儿作品不要让新注册账号顶替

View File

@@ -1,6 +1,6 @@
# Genarrative 项目共享概览
更新时间:`2026-06-03`
更新时间:`2026-06-12`
## 一句话定位
@@ -34,7 +34,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台,把 A
server-rs + Axum + SpacetimeDB
```
当前 SpacetimeDB crate、SDK、CLI / standalone、生成 bindings 和容器压测镜像统一按 `2.4.1` 对齐。
当前 SpacetimeDB crate、SDK、CLI / standalone、生成 bindings 和容器压测镜像统一按 `2.5.0` 对齐;遇到版本不匹配时先升级到 `server-rs/Cargo.toml` 锁定版本,升级后重启对应 SpacetimeDB 进程再重试
职责边界:

View File

@@ -1,22 +1,22 @@
# 团队协作约定
> 用途:约定 3 名开发人员在各自本地 Hermes 中协作开发、共享项目记忆的方式。
> 用途:约定 3 名开发人员在各自本地开发环境和 Agent 中协作开发、共享项目记忆的方式。
## 基本模式
- 每位开发人员在自己的电脑上使用本地 Hermes
- 每位开发人员在自己的电脑上使用本地 Agent
- 每位开发人员本地拉取同一个项目仓库,独立修改代码、运行测试、提交分支。
- 团队共享内容优先放在本仓库 `.hermes/``docs/` 中,通过 Git 同步。
- 团队共享内容优先放在本仓库 `docs/project-memory/``docs/` 中,通过 Git 同步。
- 不共享个人 `~/.hermes` 目录。
## 共享与禁止共享
推荐共享:
- `.hermes/shared-memory/` 团队级长期记忆
- `.hermes/plans/` 阶段性实施计划
- `.hermes/todos/` 已确定需要执行、但尚未进入实施的共享 TODO 计划
- `.hermes/skills/` 未来可复用仓库级 skills
- `docs/project-memory/shared-memory/` 团队级长期记忆
- `docs/project-memory/plans/` 阶段性实施计划
- `docs/project-memory/todos/` 已确定需要执行、但尚未进入实施的共享 TODO 计划
- `.hermes/skills/` Hermes 专用仓库级 skills
- `docs/` 中 PRD、设计、技术、经验、审计、查询手册
- `AGENTS.md` 项目级 Agent 约束
@@ -33,7 +33,7 @@
1. 拉取最新代码。
2. 阅读 `AGENTS.md`
3. 阅读 `.hermes/shared-memory/` 中与任务相关的文件。
3. 阅读 `docs/project-memory/shared-memory/` 中与任务相关的文件。
4. 阅读 `docs/README.md` 和任务相关分类 README。
5. 阅读对应 PRD、设计、技术、经验或审计文档。
6. 如果文档不足以指导编码,先补充或修正文档。
@@ -54,8 +54,8 @@
1. 运行与修改范围匹配的测试或验证命令。
2. 更新相关 `docs/` 文档。
3. 新增或沉淀 Markdown 文档时,确认文件名已使用 `【标签名】` 前缀。
4. 若产生长期有效知识,更新 `.hermes/shared-memory/`
5. 若形成可复用流程,考虑沉淀到 `.hermes/skills/`
4. 若产生长期有效知识,更新 `docs/project-memory/shared-memory/`
5. 若形成 Hermes 专用可复用流程,考虑沉淀到 `.hermes/skills/`
6. 提交代码时,提交标题使用中文;标题后逐行写明本次提交修改了什么,每条变更单独一行。
## 文档阅读顺序
@@ -64,7 +64,7 @@
1. `README.md`
2. `AGENTS.md`
3. `.hermes/shared-memory/`
3. `docs/project-memory/shared-memory/`
4. `docs/README.md`
5. `docs/experience/README.md`
6. `docs/audits/README.md`

View File

@@ -4,7 +4,7 @@
`PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图生成完成后刷新恢复的两个纯函数:`normalizeRecoveredPuzzleDraftSession``hasRecoverableGeneratedPuzzleDraft`。旧逻辑只要草稿有 `coverImageSrc`、首关 cover 或候选图,就会把恢复会话的 draft 和首关 `generationStatus` 抬成 `ready`,再进入结果页。
`.hermes/shared-memory/pitfalls.md` 已记录拼图待发布判定偏弱时只有首图但缺关卡画面、UI spritesheet 或关卡背景的半成品会被误当完成,用户进入结果页后仍可能空图或无法发布。
`docs/project-memory/shared-memory/pitfalls.md` 已记录拼图待发布判定偏弱时只有首图但缺关卡画面、UI spritesheet 或关卡背景的半成品会被误当完成,用户进入结果页后仍可能空图或无法发布。
本切片先修前端恢复链路:只有完整首关资产包存在时,恢复流程才视为可完成。后端 `build_result_preview` / `validate_publish_requirements` / `is_puzzle_session_snapshot_publish_ready` 的发布门槛收紧另作后续切片,不混入本次前端模型收口。

View File

@@ -277,7 +277,7 @@
19.3.47. `PlatformDarkModalFooter` 继续从标准双按钮 footer 扩展到 detail / confirm 收尾:`NpcModals.tsx` 的交易详情单按钮 footer 与 `MapModal.tsx` 的场景切换确认 footer 已接入共享 dark footer frame分别保留“关闭”单 CTA 和“取消 / 确认前往”双 CTA 的业务语义、按钮 tone 与禁用态。后续 dark / pixel modal 里若只是标准底部分隔线 + 常规动作区排布,优先直接复用 `PlatformDarkModalFooter`,即使只有单个按钮也不再手写 `flex justify-end`;但像 `SquareImageCropModal.tsx` 这类白底弹窗 footer、sticky 工作台 footer 和运行态 HUD 工具条继续留在各自语义壳层,不强行混到 dark footer 抽象里。验证命令:`npx vitest run src/components/NpcModals.test.tsx src/components/MapModal.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.48. `RpgEntryHomeView.tsx` 里的分类筛选工具条继续从页面内重复 JSX 收口到 `src/components/common/PlatformFilterToolbar.tsx`;该 Module 只承接“筛选按钮 + 横向 tabs + 排序按钮”的结构排布,暴露 `mobile / desktop` 两种 layout 以覆盖移动端 divider + 独立排序行和桌面端同排布局差异,但不持有分类列表、筛选状态、空态或排序逻辑。当前 RPG 首页分类区已接入,后续若其它白底列表页也出现同构的筛选壳层,可直接复用这套薄结构组件;若场景只是在单页内局部重复、接口会为了兼容业务差异不断膨胀,则优先退回文件内 helper不把 `common` 扩成假的“万能筛选条”。验证命令:`npx vitest run src/components/common/PlatformFilterToolbar.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.49. `SquareImageCropModal.tsx` 的白底 modal 壳层与 footer 已收口到 `src/components/common/UnifiedModal.tsx``UnifiedModal` 为此只薄补了 `titleId``closeIcon` 透传,继续由调用方决定 `closeOnBackdrop``closeOnEscape``portal`、header/footer 样式和按钮内容,不额外掺入 profile 业务语义,也不让 `common/` 反向依赖 `platform-entry/``SquareImageCropModal.tsx` 继续保留裁剪拖拽、pointer capture、保存禁用态与两列等宽 footer 行为,只把 header / body / footer 外壳交给共享 modal 承接。后续 `common` 级白底工具弹窗若只是标准标题栏 + 内容区 + footer 按钮排布,优先先看 `UnifiedModal` 是否够用,再决定是否需要新的薄壳;不要为了一个弹窗把 `PlatformProfileModalShell` 之类带页面语义的壳层倒灌回 `common`。验证命令:`npx vitest run src/components/common/SquareImageCropModal.test.tsx src/components/common/UnifiedModal.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.50. `CreativeImageInputPanel.tsx` 里内嵌的 white tool modal 继续并回 `UnifiedModal` 体系:参考图预览与主图预览都改成直接复用 `src/components/common/UnifiedModal.tsx`,继续保留各自 `max-w` / `max-h` 节奏、点击遮罩关闭与紧凑 header移除图片确认改成复用 `src/components/common/UnifiedConfirmDialog.tsx`,不再在 panel 内手写 `platform-modal-backdrop + platform-modal-shell + 两列按钮`。这次没有新增 `PlatformImagePreviewModal`,因为当前预览弹窗差异还只在尺寸和文案层,继续直接组合 `UnifiedModal` 更深、更稳。后续 `common` 级图片面板若出现同类“预览大图 + 单标题栏 + 关闭按钮”弹窗,优先先复用 `UnifiedModal` 并把尺寸/文案留在调用方;只有当至少两到三个调用点开始重复同一套 preview body/header adapter 时,再考虑补新的薄壳。验证命令:`npx vitest run src/components/common/CreativeImageInputPanel.test.tsx src/components/common/UnifiedModal.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.50. `CreativeImageInputPanel.tsx` 里内嵌的图片查看器改为 `src/components/common/PlatformImagePreviewModal.tsx`:参考图预览与主图预览都使用黑底全屏查看器,底层继续委托 `UnifiedModal size="fullscreen"` 承接 dialog / portal / Escape 语义,但 overlay、panel 和 body 必须强制全屏黑底,避免透出原页面或白底工具面板。查看器固定提供缩小、重置、放大和关闭图标按钮,缩放范围夹在 `1x-4x`;图片先按视口完整 contain放大后拖拽位移按缩放后的图片边界夹取不能把图片拖到露出背景。移除图片确认继续复用 `src/components/common/UnifiedConfirmDialog.tsx`,不和全屏查看器混同。后续 `common` 级图片大图预览优先复用 `PlatformImagePreviewModal`,若只是裁剪、选择或编辑工具弹窗,再回到 `UnifiedModal` / `PlatformToolModalShell` 的白底工具语义。验证命令:`npm run test -- src/components/common/PlatformImagePreviewModal.test.tsx src/components/common/CreativeImageInputPanel.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.51. `PlatformReportDialog.tsx``PublishShareModal.tsx` 共同的工具信息弹窗壳层继续收口到 `src/components/common/PlatformUtilityInfoModal.tsx`;该 Module 只承接平台主题 overlay、白底 panel以及 body / footer 的基础间距与标准 footer frame底层继续委托 `UnifiedModal.tsx`,不吸收报告字段列表、分享正文、复制逻辑、渠道按钮或品牌图标这些业务内容。`PlatformReportDialog.tsx` 继续保留 `PlatformInfoBlock` 字段列表与 joined report copy 行为,`PublishShareModal.tsx` 继续保留分享文案、主复制动作和渠道按钮网格;后续 `common` 级白底工具信息弹窗若只是重复这套“共享 modal 外壳 + 业务正文 / footer 内容”的骨架,优先复用 `PlatformUtilityInfoModal`,只有当正文编排或 footer 交互明显偏离时才回退到直接组合 `UnifiedModal`。验证命令:`npx vitest run src/components/common/PlatformUtilityInfoModal.test.tsx src/components/common/PlatformReportDialog.test.tsx src/components/common/PublishShareModal.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.52. profile 白底 modal 里的摘要头、列表骨架和内容行继续沉到 `src/components/common/PlatformProfileSummaryHeader.tsx``src/components/common/PlatformProfileSkeletonList.tsx``src/components/common/PlatformProfileContentRow.tsx`;这三个 Module 只承接 `kicker + title + badge` 的摘要层次、重复 skeleton 列表行,以及 `PlatformSubpanel` 上的 `div / button` 内容行语义,不持有账单金额、任务进度、邀请用户信息、充值商品结构或 modal 状态切换逻辑。`PlatformProfileWalletLedgerModal.tsx``PlatformProfileTaskCenterModal.tsx``PlatformProfilePlayedWorksModal.tsx``PlatformProfileReferralModal.tsx``PlatformProfileRechargeModal.tsx` 已接入;后续 profile 副弹层若只是重复这三类白底内容骨架,优先继续复用这组薄组件,不再把 skeleton、摘要头和 row chrome 写回各自 modal。验证命令`npx vitest run src/components/common/PlatformProfileModalContent.shared.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
19.3.53. 认证入口白底弹窗壳层收口到 `src/components/auth/PlatformAuthModalShell.tsx`;该 Module 只承接平台主题 overlay、`platform-auth-card`、标准标题栏、关闭按钮、点击遮罩关闭和禁用 Escape 的认证弹窗策略,不持有短信 / 密码登录、重置密码、邀请码规范化、法律协议或错误状态。`LoginScreen.tsx``RegistrationInviteModal.tsx` 已接入,业务组件只保留表单状态与提交流程。后续认证域新增同形态白底弹窗时优先复用该壳层;账号安全详情和绑定手机号这类布局差异较大的卡片先独立评估,不把 auth shell 扩成万能认证容器。验证命令:`npx vitest run src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`

View File

@@ -0,0 +1,204 @@
# 外部生成 Worker 化方案
更新时间:`2026-06-12`
## 背景
当前 VectorEngine `gpt-image-2`、音频、LLM 等外部生成链路多数由 `api-server` 的 HTTP handler 直接等待上游、OSS 持久化和 SpacetimeDB 回写完成。前端虽然有生成页和会话轮询,但 HTTP 进程仍承担长耗时副作用,导致接入更多玩法或大图生成时只能放大 API 进程,而不能单独扩展外部生成吞吐。
## 目标
- 默认 `queue` 模式下,`api-server` 的 HTTP 角色只负责鉴权、入参校验、扣费前置/状态初始化、任务入队和返回 `queued` 操作结果。
- 外部生成副作用由独立 `external-generation-worker` 角色执行。
- 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。
- 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。
- SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。
- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`;本轮扩展到跳一跳、拼消消和敲木鱼的外部图片生成动作。后续玩法继续复用同一队列 Module不再为每个玩法发明独立队列。
- 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 jobjob 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。阶段进度只作为 `request_payload_json` / 业务 session 的展示状态,不作为队列调度单位。
- 不调用外部图片 / 音频 / LLM provider 的动作继续 inline 执行,不为了统一排队而进入 `external_generation_job`
## Module 与 Interface
新增深一点的 **外部生成任务 Module**Interface 收敛为:
- `enqueue_external_generation_job_and_return`:按 `dedupe_key` 幂等创建或返回现有任务。
- `claim_external_generation_jobs_and_return`worker 按 `worker_id``limit` 和 lease 时长抢占 `pending` 或 lease 过期的 `running` 任务,返回本次 claim 的 `lease_token`
- `renew_external_generation_job_lease_and_return`worker 长任务执行期间按 `worker_id + lease_token` 续租,防止外部生成超过单次 lease 后被重复领取。
- `complete_external_generation_job_and_return`worker 成功后按 `worker_id + lease_token` 写入 `result_payload_json`,任务进入 `completed`
- `fail_external_generation_job_and_return`worker 失败后按 `worker_id + lease_token` 回写错误,并按 `max_attempts` 决定回到 `pending` 重试或进入 `failed`
- `get_external_generation_queue_stats_and_return`controller 读取队列积压、运行中任务和过期 lease 数量,用于计算 worker 目标实例数;该 procedure 只读 `external_generation_job`,不直接操作 systemd。
- `get_external_generation_job_and_return`:按 `job_id` 读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。
这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿、玩法草稿回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。
## BFF 状态接口
队列状态对前端只通过 `api-server` BFF 暴露,不允许前端直接查询 SpacetimeDB private table
- `GET /api/runtime/external-generation/queue-overview`:队列概览,用于 `我的` 页签、调试面板或后台观测当前用户可见的等待状态。返回 pending / running / completed / failed / cancelled 数量、最早等待时间、当前可见 job 摘要,以及是否存在过期 lease 需要等待 worker 重领。
- `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId``jobKind``sourceModule``sourceEntityId``status``attempt``maxAttempts``createdAt``startedAt``completedAt``updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。
BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍以 `external_generation_job` 为准,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见队列概览放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口也不把 private `request_payload_json` 原样传给前端。
## 任务表
新增私有表 `external_generation_job`
| 字段 | 说明 |
| --- | --- |
| `job_id` | 主键,`extgen-` 前缀 UUID |
| `dedupe_key` | 唯一键,建议为 `play/action/session/scope` |
| `job_kind` | 执行类型,当前拼图为 `puzzle_compile_draft``puzzle_generate_images``puzzle_generate_ui_background` |
| `owner_user_id` | 触发用户 |
| `source_module` | 玩法或能力名,例如 `puzzle` |
| `source_entity_id` | session/profile/work 等作用域 |
| `request_label` | 排障标签 |
| `request_payload_json` | worker 执行入参 JSON |
| `status` | `pending/running/completed/failed/cancelled` |
| `attempt` / `max_attempts` | 当前尝试次数与最大尝试次数 |
| `last_error_message` | 最近失败原因 |
| `worker_id` | 当前 lease owner |
| `lease_expires_at` | lease 到期时间 |
| `lease_token` | 本次 claim 的 fencing token用于阻止过期 worker 回写 |
| `available_at` | 下次可领取时间 |
| `result_payload_json` | 完成摘要 |
| `created_at/started_at/completed_at/updated_at` | 审计时间 |
索引:
- `by_external_generation_job_status_available(status, available_at)`
- `by_external_generation_job_worker_id(worker_id)`
- `by_external_generation_job_source(source_module, source_entity_id)`
- `by_external_generation_job_owner_user_id(owner_user_id)`
## 状态机
```text
pending -> running -> completed
pending -> running -> pending (可重试失败)
pending -> running -> failed (达到最大重试次数)
pending/running -> cancelled (预留)
```
`claim` 只领取 `pending``available_at <= now` 的任务,或 `running``lease_expires_at <= now` 的任务。领取时递增 `attempt`、写入 `worker_id``started_at`、新的 `lease_expires_at``lease_token`。SpacetimeDB procedure 使用 `ctx.timestamp` 作为状态流转时间,只从 worker 入参读取“时长差值”,不信任 worker 本机绝对时间。worker 每次执行只处理自己 claim 到的任务;续租、完成或失败时必须带同一个 `worker_id + lease_token`,且当前 lease 尚未过期,防止过期 worker 覆盖新 lease。
玩法业务写回也必须在 SpacetimeDB 同一事务里校验 lease fencing。拼图的 `compile_puzzle_agent_draft` worker 调用、`save_puzzle_generated_images``save_puzzle_ui_background``mark_puzzle_draft_generation_failed``mark_puzzle_level_generation_failed``queue` 模式下会带 `external_generation_job_id / worker_id / lease_token`,并校验 job 仍为 `running`、token 未过期、`job_kind``owner_user_id``source_module``source_entity_id` 均匹配后才写 session / work profile。`inline` 模式不创建 `external_generation_job`,因此这三个 guard 字段必须同时为空transaction 只把三项全空识别为 api-server 受控同步写回三项半空仍按非法请求拒绝。worker 路径的核心业务写回失败不能返回内存快照并把 job 标为 `completed`;失败态业务回写成功后才允许把队列 job 标为 `failed`,失败态仍未写回时保留当前租约并等待后续 lease 过期重领,避免队列状态和真实 session 脱节。api-server 的资产扣费包装遇到这类 stale worker lease guard 错误时不执行补偿退款,避免旧 worker 冲掉后续合法 worker 的同一账本扣费。
## 执行模式与进程角色
外部生成执行模式由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制:
- `queue`默认值HTTP handler 入队 `external_generation_job`,由 `external-generation-worker` 角色 claim lease 后执行;生产、预发和压测默认使用该模式。
- `inline`HTTP handler 直接调用同一个 worker executor同步等待 provider、OSS 和 SpacetimeDB 写回完成后返回 `operation.status = completed`只用于本地或低并发排查不提供队列持久化、lease 重领和 worker 横向扩容。
同一个 Rust binary 通过 `GENARRATIVE_PROCESS_ROLE` 切换:
- `api`:只启动 HTTP server。
- `external-generation-worker`:只启动外部生成 worker不监听 HTTP。
- `external-generation-controller`:只启动 worker controller不监听 HTTP也不直接执行外部生成任务。
- `all`:本地开发可同时启动 HTTP 与 worker。
worker 配置:
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`:实例 ID未配置时用 hostname/pid 派生。
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY`:单进程并发领取/执行数量。
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS`:空队列轮询间隔。
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS`:任务 lease 时长worker 会按约三分之一 lease、最长 30 秒的间隔续租。该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。
controller 配置:
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MIN_WORKERS`:保底 worker 实例数,生产默认 `1`controller 不会主动停止 `@1`
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MAX_WORKERS`:自动扩容上限,生产模板默认 `8`
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_TARGET_JOBS_PER_WORKER`:每个 worker 实例承担的目标未完成任务数,默认 `2`;目标实例数按 `claimable_pending + running_active + expired_running` 计算后夹在 min/max 之间,避免把已包含过期 running 的 `claimable_count` 重复计入。
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_POLL_INTERVAL_MS`controller 轮询队列统计的间隔,默认 `10000`
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SCALE_DOWN_IDLE_ROUNDS`:连续多少轮无可领取、无运行中、无过期 running 后才允许缩容,默认 `6`;缩容每轮只停止最高编号的一个实例。
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SERVICE_TEMPLATE`systemd worker 模板,默认 `genarrative-external-generation-worker@{}.service`
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_DRY_RUN`:只记录决策不执行 systemctl默认 `false`
动态缩扩容方式:生产默认由 `deploy/systemd/genarrative-external-generation-controller.service` 启动 `GENARRATIVE_PROCESS_ROLE=external-generation-controller`controller 读取 `get_external_generation_queue_stats_and_return` 后对 `genarrative-external-generation-worker@N.service` 执行精确 `systemctl start/stop`;无需改变 HTTP 进程数。controller 只操作 `@1..@MAX` 中的缺口或最高编号多余实例,保留 `@1` 作为保底 worker。缩容或发布重启 worker 时,进程收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务会在 lease 过期后被其它 worker 重新领取。容器链路已有独立 `external-generation-worker` compose service扩 worker 必须扩这个 worker service不能只扩 `api-server` HTTP service。
## 已接入的拼图纵切
### 拼图
`compile_puzzle_draft`
1. HTTP handler 保存拼图表单草稿;`queue` 模式下 `queued/running` 的持久事实源是 `external_generation_job`,不把 HTTP 进程变成外部生成执行者。
2. `queue` 模式下 HTTP handler 入队 `puzzle_compile_draft`,返回 `operation.status = queued` 和当前 session。拼图 dedupe key 包含本次 `extgen-` job id只保证同一任务行唯一不把同一 session 后续重新生成吞掉。`inline` 模式下 HTTP handler 复用同一 executor 同步执行,成功后直接返回 `completed` 和最新 session。
3. 前端保持 `puzzle-generating`,继续轮询 `getPuzzleAgentSession`;首期不把 `queued/running` 写回 `puzzle_agent_session`,因此刷新或跨设备恢复生成中状态仍是后续 read model 工作。
4. worker claim 后执行原有 `compile_puzzle_draft_with_initial_cover``compile_puzzle_draft_with_uploaded_cover`;前置 `compile_puzzle_agent_draft` 也必须携带本次 `job_id / worker_id / lease_token`,防止过期 worker 先把草稿卡和 session 写到 ready。
5. 成功后沿原有 SpacetimeDB 拼图会话/作品写回,前端轮询看到 `progressPercent >= 94/96/100` 和 ready 草稿。
6. 失败后调用 `mark_puzzle_draft_generation_failed`,拼图首期业务失败直接进入 failed只有失败态写回成功才把队列 job 标为 failed失败态写回失败则保留租约等待重领。队列仍保留 lease 过期后的崩溃重领,避免 worker 退款后再次成功导致钱包账本漂移。前端通过现有失败草稿/弹窗机制展示来源错误。
`generate_puzzle_images`
1. HTTP handler 校验本次 `levelsJson` 快照;`queue` 模式下入队 `puzzle_generate_images` 并返回 `operation.status = queued/running/completed/failed``inline` 模式下同步执行原 worker executor 并在成功后返回 `completed`
2. worker 执行原结果页关卡图链路自动命名、VectorEngine / 上传图直用、关卡场景图、UI spritesheet、关卡背景资产包、OSS 持久化和 SpacetimeDB 回写。
3. 成功后 `save_puzzle_generated_images` 写回目标关卡和草稿卡;失败后 `mark_puzzle_level_generation_failed` 只标记目标关卡 `failed`,不污染已 ready 的其它关卡。队列 job 只有在目标关卡失败态写回成功后才进入 failed。
4. 前端结果页对 `queued/running` 操作继续轮询 `getPuzzleAgentSession`,目标关卡变为 ready 或 failed 后收敛。
`generate_puzzle_ui_background`
1. HTTP handler 校验本次 `levelsJson` 快照;`queue` 模式下入队 `puzzle_generate_ui_background` 并返回 `operation.status = queued/running/completed/failed``inline` 模式下同步执行原 worker executor 并在成功后返回 `completed`
2. worker 执行原结果页 UI 背景链路归一化提示词、VectorEngine 生成、OSS 持久化和 `save_puzzle_ui_background` 写回。
3. 成功后目标关卡写入 `uiBackgroundPrompt/uiBackgroundImageSrc/uiBackgroundImageObjectKey`;失败后复用 `mark_puzzle_level_generation_failed` 标记目标关卡 `failed`,并在失败态写回成功后才终结队列 job让前端轮询能收敛。
### 跳一跳、拼消消和敲木鱼扩展范围
以下动作按同一 worker 模式迁移。命名以现有玩法 action 为准,队列 `job_kind` 采用后端稳定 snake_case不新增平行队列
- 跳一跳 `jump-hop`
- `compile-draft`:草稿编译阶段需要生成地块 / 视觉资产时入队,例如 `jump_hop_compile_draft`
- `regenerate-tiles`:结果页地块图集重生入队,例如 `jump_hop_regenerate_tiles`
- 拼消消 `puzzle-clear`
- `compile-draft`:草稿编译阶段需要生成场地底图和卡片 atlas 时入队,例如 `puzzle_clear_compile_draft`
- `regenerate-atlas`:结果页素材 atlas 重生入队,例如 `puzzle_clear_regenerate_atlas`
- 敲木鱼 `wooden-fish`
- `compile-draft`:草稿编译阶段需要生成背景、敲击物或其它图片资产时入队,例如 `wooden_fish_compile_draft`
- `regenerate-hit-object`:结果页敲击物图片重生入队,例如 `wooden_fish_regenerate_hit_object`
这些动作首版都保持“单动作单 job”一次 `compile-draft` 或一次 `regenerate-*` 请求只创建一个 jobworker 内部负责该动作所需的 provider 调用、素材处理、OSS 持久化、失败态写回和业务成功写回。非外部图片生成动作,例如纯元信息保存、标签编辑、发布、试玩启动、运行态动作、删除和公开 read model 读取,继续 inline 执行。
每个玩法迁移时必须同时接入业务写回 lease guardworker 路径带 `external_generation_job_id / worker_id / lease_token`inline 路径三项同时为空。过期 worker 不得写 session / work profile业务失败态写回成功后才允许 job 进入 `failed`
## 验收
基础检查:
```bash
npm run spacetime:generate
npm run check:spacetime-schema
npm run check:server-rs-ddd
cargo check -p api-server --manifest-path server-rs/Cargo.toml
```
定向测试:
```bash
cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml
cargo test -p spacetime-module level_generation_failure --manifest-path server-rs/Cargo.toml
cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml
npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx -t "keeps generation progress visible"
npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "compile_puzzle_draft"
```
本地 smoke
```bash
GENARRATIVE_PROCESS_ROLE=all npm run dev
curl -f http://127.0.0.1:<api-port>/healthz
```
本地 `npm run dev` 默认保持 `inline` 开发体验:未显式配置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,普通本地联调可以同步确认 provider、OSS 和 SpacetimeDB 写回链路本身是否可行。需要验证 worker 队列、BFF 队列状态、lease 重领或扩缩容时,必须显式使用 `queue`,并启动 worker 角色;可以用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server` 做临时单进程 smoke也可以使用隔离容器 smoke。
生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认队列概览 BFF 可读、单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。
systemd 生产 controller 与手动兜底示例:
```bash
systemctl enable --now genarrative-external-generation-worker@1.service
systemctl enable --now genarrative-external-generation-controller.service
systemctl start genarrative-external-generation-worker@2.service
systemctl stop genarrative-external-generation-worker@2.service
systemctl status genarrative-external-generation-controller.service 'genarrative-external-generation-worker@*.service'
```

View File

@@ -1,6 +1,6 @@
# server-rs 与 SpacetimeDB 数据契约
更新时间:`2026-06-10`
更新时间:`2026-06-12`
## 后端主线
@@ -16,7 +16,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.4.1`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings 和容器压测镜像也必须与 `2.4.1` 对齐,避免 BSATN / procedure result 反序列化错配。
SpacetimeDB 版本口径:当前 Rust crate `spacetimedb``spacetimedb-sdk``spacetimedb-lib` 统一锁定 `2.5.0`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings 和容器压测镜像也必须与 `server-rs/Cargo.toml` 锁定版本对齐,避免 BSATN / procedure result 反序列化错配。遇到版本不匹配时,不继续沿着业务超时排查,先把 CLI / standalone 直接升级到锁定版本并重启后再重试。
当前主要 crate
@@ -113,7 +113,7 @@ npm run check:server-rs-ddd
- `server-rs/crates/api-server/src/state.rs` 中的 `PuzzleApiState` 是拼图 HTTP/BFF 的 Feature State集中暴露 `SpacetimeClient``PuzzleGalleryCache`、OSS client、作者查询所需认证服务、拼图 LLM client 和少量 VectorEngine / Agent 配置快照。拼图 handler 只提取 `State<PuzzleApiState>`,不得重新改回 `State<AppState>`
- `server-rs/crates/api-server/src/puzzle.rs` 只作为聚合入口,保留共享 import / 常量、内部模块声明和 handler re-export不继续承载大段实现。
- `server-rs/crates/api-server/src/puzzle/handlers.rs` 承接 Axum handler负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper并返回 HTTP/SSE 响应。
- `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt、降级 snapshot 和初始资产就绪校验。
- `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt 和初始资产就绪校验。
- `server-rs/crates/api-server/src/puzzle/generation.rs` 承接拼图图片与 UI 背景的生成编排、计费包裹和 reference image 路径选择。
- `server-rs/crates/api-server/src/puzzle/vector_engine.rs` 承接 VectorEngine 请求体、HTTP 调用、下载 / base64 解码、OSS 写入、asset object / binding 持久化和上游错误归一。
- `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。
@@ -233,6 +233,12 @@ npm run check:server-rs-ddd
- Rust 结构体:`AiTaskStage`
- 源码:`server-rs/crates/spacetime-module/src/ai/stages.rs`
### `external_generation_job`
- Rust 结构体:`ExternalGenerationJob`
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
- 用途:外部生成 worker 的持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft``generate_puzzle_images``generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity避免过期 worker 写 session / work profile`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job业务失败态写回成功后才能 fail job失败态未写回时保留租约等待后续重领。
### `ai_text_chunk`
- Rust 结构体:`AiTextChunk`
@@ -669,6 +675,8 @@ npm run check:server-rs-ddd
- Rust 结构体:`PuzzleWorkProfileRow`
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
- 说明:拼图作品 profile 表,保存草稿 / 已发布作品的标题、作者、关卡、封面、发布状态、可见性、基础游玩数、点赞数、改造数和积分激励领取状态。
- 字段变更:`visible` 控制是否进入公开列表 / 详情、通关后的推荐下一作品候选、公开点赞 / Remix 和正式公开 runtime默认 `true`。后台隐藏后作品可保留 `publication_status = Published`,但公开消费路径必须按 `Published + visible=true` 判断。
### `puzzle_clear_agent_session`
@@ -714,14 +722,14 @@ npm run check:server-rs-ddd
- Rust view`puzzle_gallery_view`
- 返回类型:`Vec<PuzzleWorkProfile>`
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
- 说明:拼图广场公开详情 source / 兼容投影,只暴露 `publication_status = Published` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view只保留平台详情页展示摘要。
- 说明:拼图广场公开详情 source / 兼容投影,只暴露 `publication_status = Published``visible = true` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view只保留平台详情页展示摘要。
### SpacetimeDB view`puzzle_gallery_card_view`
- Rust view`puzzle_gallery_card_view`
- 返回类型:`Vec<PuzzleGalleryCardViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
- 说明:拼图公开列表 source 投影,只暴露前端列表卡片需要的公开字段,不携带 levels / anchor_pack 等详情级载荷;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view`/api/runtime/puzzle/gallery` 保留旧 HTTP shape并从统一 public cache 映射回 `PuzzleGalleryResponse`
- 说明:拼图公开列表 source 投影,只暴露 `publication_status = Published``visible = true` 的公开字段,不携带 levels / anchor_pack 等详情级载荷;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view`/api/runtime/puzzle/gallery` 保留旧 HTTP shape并从统一 public cache 映射回 `PuzzleGalleryResponse`
### 拼图公开列表 HTTP 窗口缓存

View File

@@ -1,11 +1,11 @@
# 本地开发验证与生产运维
更新时间:`2026-06-09`
更新时间:`2026-06-12`
## 标准开发流程
```text
同步代码 -> 读 AGENTS.md / .hermes 共享记忆 -> 查当前 docs -> 小步实现 -> 本地验证 -> 更新 docs / .hermes -> 提交
同步代码 -> 读 AGENTS.md / docs/project-memory 共享记忆 -> 查当前 docs -> 小步实现 -> 本地验证 -> 更新 docs / project-memory -> 提交
```
如果当前文档不足以指导编码,先补文档再落地工程修改。
@@ -51,6 +51,14 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模
开发态 `npm run dev``npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。
本地 `npm run dev``npm run dev:api-server` 默认保留 inline 开发体验:未显式设置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,外部生成 handler 会同步复用 worker executor完成后返回 `completed`,便于快速确认 provider、OSS 和 SpacetimeDB 写回链路。inline 不创建 `external_generation_job`,也不能验证 worker lease、队列等待展示或动态扩缩容。
本地排查外部内容生成 worker 队列时,必须显式使用 queue例如 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server`,让同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api``external-generation-worker``external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前进入持久队列的外部图片生成动作包括:拼图 `compile_puzzle_draft` / `generate_puzzle_images` / `generate_puzzle_ui_background`,跳一跳 `compile-draft` / `regenerate-tiles`,拼消消 `compile-draft` / `regenerate-atlas`,敲木鱼 `compile-draft` / `regenerate-hit-object`。非外部图片生成动作继续 inline不进入队列。worker 数量为 0 时HTTP 只返回 queued/running不会兜底执行外部 provider。
`我的` 页签或排障面板展示队列等待时,只读取 BFF 队列接口:`GET /api/runtime/external-generation/queue-overview` 查看当前用户可见队列概览,`GET /api/runtime/external-generation/jobs/{jobId}` 查看单 job 状态。生成页 / 进度页不承接队列概览,只展示当前玩法业务进度;队列接口只提供等待 / 运行 / 失败 / 完成状态补充,最终草稿、作品和结果页仍要轮询对应玩法 session/detail 接口收敛到 ready 或 failed不要直接查询 `external_generation_job` private table也不要把 worker 内部 payload 暴露到前端。
需要验证“更新 API 不停 worker”和“worker 是否持续消费队列”时,优先使用隔离容器 smoke`npm run container:worker-smoke -- smoke`。该脚本生成 gitignored 的 `deploy/container/worker-smoke/api-server.env`,启动独立 compose project 与独立 SpacetimeDB发布当前 `spacetime-module` 后写入 `worker_smoke_unsupported` 测试 job预期 worker claim 后执行 unsupported 失败分支,再执行 API-only recreate 并确认 worker 容器 ID 不变,最后再次入队验证 API 更新后队列仍可消费。`external_generation_job` 是 private table脚本通过 worker 日志确认 job_id 被消费,不用 CLI SQL 查询私表。该 smoke 不读取 `.env.local`,也不依赖真实 VectorEngine / OSS 密钥;真实生图链路联调再在本地私有 env 中补齐 provider 配置。worker-smoke 默认把本机 `spacetime` CLI 打成轻量 SpacetimeDB 镜像,避免本机首次 smoke 依赖官方大镜像下载。若容器内 Cargo 拉取 crates.io 依赖不稳定,可用 `npm run container:worker-smoke -- smoke --local-binary` 让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入 Debian bookworm smoke runtime 临时镜像;可用 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE` 覆盖运行时基础镜像;若隔离端口或库数据需要重建,追加 `--force`。完成 queue 链路验证时,还要用队列概览 BFF 和单 job 状态接口确认 job 从 queued/running 收敛,并用对应玩法 session/detail 接口确认业务状态同步完成。
本地只做账号/UI smoke 且需要短信登录时,`SMS_AUTH_PROVIDER` 应显式设为 `mock`,并把 `SMS_AUTH_MOCK_VERIFY_CODE` 设为固定值(当前常用 `123456`),再重启 `npm run dev``npm run dev:api-server`。如果 `.env.local` 还保留 `SMS_AUTH_PROVIDER=aliyun``POST /api/auth/phone/login` 用 mock 验证码会稳定报“验证码错误”,不是前端表单问题。真实短信联调再切回 `aliyun` 并重启。
微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID``WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY``WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY``WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin``buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods``productId` 对应微信后台道具 ID。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`
@@ -69,7 +77,7 @@ spacetime sql <database> "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv
本地 `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.4.1`。若版本错配procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml``spacetimedb = "..."`需要切版本时执行 `spacetime version install <version> && spacetime version use <version>`然后重新启动 `npm run dev:spacetime`。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.5.0`。若版本错配procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml``spacetimedb = "..."`遇到版本不匹配时不要继续深挖业务超时,直接执行 `spacetime version install <version> && spacetime version use <version>`或在目标就是最新版本时执行 `spacetime version upgrade`,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。
本地 `.env``.env.local``.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY``VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image``api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若 VectorEngine 在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 `request_id` 的 provider 日志字段 `source``source_chain``source_chain_depth`,再查 `external_api_call_failure.metadata_json.errorSource`;当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。拼图关卡资产按 `level_scene -> ui_spritesheet -> level_background` 顺序生成,日志会带 `slot``asset_kind``elapsed_ms`
@@ -133,6 +141,8 @@ npm run spacetime:generate
项目已安装 `@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/` 工具目录作为项目知识库索引源。
首次拉取或需要重建索引时:
```bash
@@ -202,7 +212,7 @@ UI 相关修改要重点验证:
## SpacetimeDB 操作规则
1. 不在人工命令、本地联调或文档示例中使用 `spacetime --root-dir`
1. 不在人工命令、本地联调或文档示例中使用 `spacetime --root-dir`CI/CD 脚本内部为隔离运行用户登录态的受控用法例外,但不得写成手工排障命令
2. 本地开发使用项目脚本维护数据目录;需要清空本地数据时先确认可丢弃,再停止服务并处理本地数据目录。
3. 发布目标必须显式 `--server` / `--server-url`
4. 身份问题先查 `spacetime login show``spacetime server list` 和目标库权限,不通过切回旧 Node / PostgreSQL 绕过。
@@ -212,7 +222,7 @@ UI 相关修改要重点验证:
### SpacetimeDB 数据目录 OSS 备份
数据库备份不放进 `spacetime-module` reducer / procedure备份属于文件系统与 OSS 外部副作用,必须由运维脚本在 SpacetimeDB 宿主外执行。当前统一脚本为;生产 provision 还会安装 `genarrative-database-backup.timer`,每天 `03:20` 左右自动执行一次 OSS 冷备份:
数据库备份不放进 `spacetime-module` reducer / procedure备份属于文件系统与 OSS 外部副作用,必须由运维脚本在 SpacetimeDB 宿主外执行。当前统一脚本为 `scripts/database-backup-to-oss.mjs`npm 命令 `npm run database:backup:oss`;生产 provision 还会安装 `genarrative-database-backup.timer`,每天 `03:20` 左右自动执行一次 OSS 冷备份:
```bash
npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service --restart-service-after genarrative-api.service
@@ -233,7 +243,7 @@ GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID=
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 单独配置最小权限账号。`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`
`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 单独配置最小权限账号。`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`
冷备份后必须做一次只读验收,不要只看 `genarrative-database-backup.service` 是否成功退出:
@@ -260,11 +270,12 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
`Genarrative-Server-Provision` 会安装并启用 `genarrative-health-patrol.timer`,默认每 5 分钟运行一次 `genarrative-health-patrol.service`。巡检脚本随 API release 归档到 `/opt/genarrative/current/scripts/ops/production-health-patrol.mjs`,只读检查:
- `genarrative-api.service``spacetimedb.service``nginx.service` 是否 active。
- `genarrative-api.service``genarrative-external-generation-controller.service``spacetimedb.service``nginx.service` 是否 active。
- 至少一个 `genarrative-external-generation-worker@*.service` 实例是否 active如果 controller 存活但 worker 全部退出,巡检直接返回 `CRITICAL`,避免外部生成队列长期无人消费。
- API 直连 `/healthz``/readyz`
- SpacetimeDB 直连 `/v1/ping`
- 默认直连 API 端口检查 `/api/creation-entry/config``/api/runtime/puzzle/gallery``/api/runtime/custom-world-gallery`;如需走 Nginx / 公网域名,在 `/etc/genarrative/health-patrol.env` 配置 `GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL=https://<域名>`
- 最近 15 分钟 `genarrative-api.service``spacetimedb.service``nginx.service``err..alert` 日志。
- 最近 15 分钟 `genarrative-api.service``genarrative-external-generation-controller.service``genarrative-external-generation-worker@*.service``spacetimedb.service``nginx.service``err..alert` 日志。
巡检输出总状态 `OK / WARNING / CRITICAL`;只有 `CRITICAL` 默认让 systemd service 失败,`WARNING` 只写日志和状态文件,避免历史日志噪声把 timer 长期打成失败。最近一次结果写入 `/var/lib/genarrative/health-patrol/status.json`。手动执行:
@@ -302,7 +313,9 @@ dev 服务器上的 Gitea 内网入口固定为 `http://10.2.0.10/GenarrativeAI/
生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git不写入文档示例。
`Genarrative-Server-Provision` 会安装 systemd 模板和 Nginx 站点模板,不再安装 clang / lld / pkg-config / OpenSSL headers / sccache 等通用构建链依赖。因 VectorEngine 图片上游 POST 已改用 `libcurl`,当前 Linux release 构建出的 `api-server` 运行时需要 `OPENSSL_3.2.0` 符号Ubuntu 24.04 apt 默认只提供 OpenSSL 3.0.x不能直接满足该符号版本。Provision 会把 OpenSSL `3.2.0` 独立安装到 `/opt/genarrative/openssl-3.2.0`,校验官方 tarball SHA256并只通过 `genarrative-api.service``LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib` 让 api-server 使用,避免替换系统 OpenSSL 或影响 ssh / nginx / apt。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter``libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点
`api-server` 进程角色由 `GENARRATIVE_PROCESS_ROLE` 控制:`api` 只监听 HTTP`external-generation-worker` 只消费外部生成队列,`external-generation-controller` 只管理 worker systemd 实例,`all` 仅用于本地或临时 smoke不隐式启动 controller。外部生成策略由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制;生产和容器压测默认保持 `queue`,本地 `npm run dev` 默认保留 `inline` 开发体验,只有显式配置 `queue` 才会落 `external_generation_job``inline` 只用于本地或低并发同步排查HTTP handler 会直接复用 worker executor,完成后返回 `completed`,但不会落 `external_generation_job`,也不能通过增加 worker 进程扩吞吐。外部生成 worker 使用同一发布包和同一套 SpacetimeDB 配置,按实例数和 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩;生产默认由 `genarrative-external-generation-controller.service` 读取 `get_external_generation_queue_stats_and_return`,按 `claimable_pending + running_active + expired_running` 计算目标 worker 数,并对 `genarrative-external-generation-worker@N.service` 精确执行 `systemctl start/stop`。controller 参数模板是 `deploy/env/external-generation-controller.env.example`:默认保底 `MIN_WORKERS=1`、上限 `MAX_WORKERS=8`、每 worker 目标 `TARGET_JOBS_PER_WORKER=2``POLL_INTERVAL_MS=10000`、连续 `SCALE_DOWN_IDLE_ROUNDS=6` 轮完全空闲才缩容;缩容每轮只停止最高编号的一个实例,且不主动停止 `@1`。worker 收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务才会在 lease 过期后由其它 worker 重领。每个 worker 实例应设置唯一 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`,默认会用主机名和 pid 兜底systemd 生产模板 `deploy/systemd/genarrative-external-generation-worker@.service` 会用 `%H-%i` 生成实例 ID并把 tracking outbox 隔离到 `/var/lib/genarrative/tracking-outbox/%H-%i``Genarrative-Server-Provision` 会安装 worker 模板、controller unit 和两份专属 env 模板,默认 enable 首个 `genarrative-external-generation-worker@1.service``genarrative-external-generation-controller.service`;首次 API deploy 会在默认 worker pattern 下自动 `enable --now genarrative-external-generation-worker@1.service` 并等待 worker active同时重启并验活 controller。手动兜底扩容仍可用 `systemctl start genarrative-external-generation-worker@2.service` / `@3.service`,缩容用 `systemctl stop genarrative-external-generation-worker@N.service`controller 下轮会按队列压力修正到目标实例数。worker 专属参数模板是 `deploy/env/external-generation-worker.env.example`,密钥与 SpacetimeDB 连接仍复用 `/etc/genarrative/api-server.env`。API 发布脚本默认会重启并验活 `genarrative-external-generation-worker@*.service` `genarrative-external-generation-controller.service`;若本次只发 HTTP 且不希望滚动 worker可传 `--no-worker-services`,若不希望重启 controller 可传 `--no-worker-controller``GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 控制空队列轮询间隔,`GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS` 控制单次 leaseworker 会约每三分之一 lease、最长 30 秒续租该值应覆盖一次心跳网络抖动窗口不需要大于完整外部生成链路耗时。SpacetimeDB 使用自身事务时间计算 claim/renew/complete/fail完成和失败回写还会校验 `lease_token` 与未过期 lease避免同一 job 被过期 worker 覆盖。首版 worker 粒度是单动作单 job不拆阶段 job当前外部图片生成动作覆盖拼图、跳一跳、拼消消和敲木鱼纯元信息保存、发布、试玩启动、运行态动作和公开读取继续 inline。当前生成业务失败只做用户重新触发不做自动业务重试避免 worker 退款和重试成功之间产生钱包账本漂移
`Genarrative-Server-Provision` 会安装 systemd 模板和 Nginx 站点模板,不再安装 clang / lld / pkg-config / OpenSSL headers / sccache 等通用构建链依赖。因 VectorEngine 图片上游 POST 已改用 `libcurl`,当前 Linux release 构建出的 `api-server` 运行时需要 `OPENSSL_3.2.0` 符号Ubuntu 24.04 apt 默认只提供 OpenSSL 3.0.x不能直接满足该符号版本。Provision 会把 OpenSSL `3.2.0` 独立安装到 `/opt/genarrative/openssl-3.2.0`,校验官方 tarball SHA256并只通过 `genarrative-api.service``LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib` 让 api-server 使用,避免替换系统 OpenSSL 或影响 ssh / nginx / apt。Ubuntu / apt 目标机为完成这一步会安装 `build-essential``ca-certificates``curl``perl``tar` 等 OpenSSL 运行时自举工具;这只服务于独立 OpenSSL 运行时安装,不代表 provision 重新承担 api-server 构建职责。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter``libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点。
50 HTTP req/s 首版压测优化口径:
@@ -311,15 +324,15 @@ dev 服务器上的 Gitea 内网入口固定为 `http://10.2.0.10/GenarrativeAI/
- `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 节点中转工具包。`Prepare Provision Tools` 在目标 dev / release agent 工作区内先检查 `/usr/local/bin/otelcol-contrib``${SPACETIME_ROOT}/bin/current`:版本已满足时直接复用目标机现有文件生成 `provision-tools/`,只有缺失或版本不匹配时才使用 `PROVISION_DOWNLOADS_DIR` 里的本地包或从配置的下载源准备 SpacetimeDB `2.4.1` / `otelcol-contrib 0.151.0`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。
- Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转工具包。`Prepare Provision Tools` 在目标 dev / release agent 工作区内先检查 `/usr/local/bin/otelcol-contrib``${SPACETIME_ROOT}/bin/current`:版本已满足时直接复用目标机现有文件生成 `provision-tools/`,只有缺失或版本不匹配时才使用 `PROVISION_DOWNLOADS_DIR` 里的本地包或从配置的下载源准备 SpacetimeDB `2.5.0` / `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 源。
- `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-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`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s``burst=4096``limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` 是反代兜底,防止拼图入口页 / 新增关卡本地参考图 Data URL 或旧兼容请求在到达 `api-server` 前被默认 1 MiB 上限拦截;拼图本地参考图前后端统一限制 6MB历史图片仍提交 `referenceImageAssetObjectId(s)`。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000``upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release 模板是否已渲染并 reload同时检查前端是否超出 6MB 或错误提交了未压缩大图。`limit_conn_status 429``limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效;若线上压测看到 `limiting connections by zone "genarrative_api_conn"` 却返回 503优先检查 `nginx -T` 里 HTTPS server 是否缺少这些状态码,以及 `/api/runtime/puzzle/gallery` 是否误落到通用 `location ~ ^/api``limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time``upstream_connect_time``upstream_header_time``upstream_response_time``upstream_status``request_id`
- 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`
- 作品列表短期继续由 `api-server` / BFF 订阅 SpacetimeDB 公开 read model 后读本地 cache不让浏览器前端直接订阅完整列表未来如新增 `public_work_gallery_entry` 等专用公开作品列表 read model前端只可订阅稳定、低基数、公开的专用投影禁止订阅 `puzzle_work_profile``custom_world_profile` 等玩法源表后自行 join、聚合或判断权限。前端直订阅落地前必须先补齐权限、字段契约、排序 / 分页、埋点和 BFF 回退策略。
- 50 HTTP req/s 验收目标为 `http_req_failed < 1%``p95 < 2s``dropped_iterations = 0`,同时压测窗口内 Nginx 无新增 502。2026-05-19 容器 2C / 2G 连续 10 轮不重启 SpacetimeDB 压测:`PEAK_RPS=2500` 等价约 5000 HTTP req/s平均实际吞吐约 `4219 HTTP req/s`10 轮总计 `1,897,357` 个 200、`212,542` 个 429、`0` 个 5xx200 请求平均 `p95=123ms``p99=234ms`;该档会把 SpacetimeDB 容器内存从约 `366MiB` 推到约 `885MiB / 896MiB`,因此当前不要继续抬公开 gallery 入口并发,应优先处理 SpacetimeDB 侧连接 / 订阅 / tracking 写入后的内存高水位。
容器化压测与隔离部署方案单独放在 `deploy/container/`,用于本机或预发模拟 Linux release + Nginx + OTLP Collector 拓扑,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。当前容器模拟参数按 `genarrative-release` 采样值收口为 2 vCPU / 2 GiB RAM / `nofile=4096` / `worker_connections=768`,并在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=768m``api-server cpus=2.0 mem_limit=1g``nginx cpus=0.25 mem_limit=128m``otelcol cpus=0.25 mem_limit=128m``k6 cpus=0.5 mem_limit=512m`。容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,只增加 Tokio worker 调度并发,不突破 `api-server cpus=2.0` 的 CPU 配额:
容器化压测与隔离部署方案单独放在 `deploy/container/`,用于本机或预发模拟 Linux release + Nginx + OTLP Collector 拓扑,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。当前容器模拟参数按 `genarrative-release` 采样值收口为 2 vCPU / 2 GiB RAM / `nofile=4096` / `worker_connections=768`,并在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m``api-server cpus=2.0 mem_limit=1g``external-generation-worker cpus=2.0 mem_limit=1g``nginx cpus=0.5 mem_limit=128m``otelcol cpus=0.25 mem_limit=128m``k6 cpus=1.0 mem_limit=512m`。容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,只增加 Tokio worker 调度并发,不突破 `api-server cpus=2.0` 的 CPU 配额;容器默认 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,可用 `npm run container:up -- --scale external-generation-worker=N external-generation-worker` 验证外部生成 worker 动态扩缩容,`inline` 模式不参与该验证
```bash
npm run container:init
@@ -332,6 +345,7 @@ npm run container:down
容器方案默认暴露 `http://127.0.0.1:18080``api-server` 在容器内监听 `0.0.0.0:8082`Nginx 通过 `api-server:8082` upstream 反代 `/api/``/admin/api/`。SpacetimeDB 也纳入 compose容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`,并安装本机 `otelcol-contrib.service`真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。完整拓扑、端口、k6 参数和 OTLP debug exporter 使用方法见 `deploy/container/README.md`
`npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`
隔离验证 worker 队列和 API-only 更新时使用 `npm run container:worker-smoke -- smoke`。该命令不复用 `deploy/container/api-server.env`,会在 `deploy/container/worker-smoke/` 生成本机专用 env 与端口 state并使用 unsupported job 验证 worker claim / fail 回写,不需要真实外部生成密钥;本机 crates.io 网络不稳时使用 `--local-binary`,由容器内 Cargo 复用本机 Cargo 缓存构建,并把产物放进 Debian bookworm smoke runtime。
OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs但本地日志与 Nginx 文件日志仍保留:
@@ -410,7 +424,7 @@ cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms
- `profile_task_reward_claim`
- `profile_wallet_ledger`
个人任务首版 scope 仅支持 `user`。每日登录任务按北京时间自然日 0 点重置;用户已登录并停留在“我的”页跨日时,前端需要先非阻断调用 refresh session 以写入新业务日 `daily_login`,再请求 `/api/profile/tasks` 刷新任务中心。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`
个人任务首版 scope 仅支持 `user`。每日登录任务按北京时间自然日 0 点重置;用户已登录并停留在“我的”页跨日时,前端需要先非阻断调用 refresh session 以写入新业务日 `daily_login`,再请求 `/api/profile/tasks` 刷新任务中心。认证成功后的 `daily_login` 必须通过 `SpacetimeClient::record_daily_login_tracking_event(...)` 调用 SpacetimeDB 专用 `record_daily_login_tracking_event_and_return` procedure由数据库事务时间生成当日幂等事件并推进任务进度不要改回普通 `record_tracking_event_after_success`、tracking outbox 或旧 `profile.login.daily` 事件键。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`
外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、errorSource、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt、userId、profileId 和 requestId其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,`requestId` 用于回查同一次 HTTP 请求日志,入口拿不到上下文时允许为空。常用查询:

View File

@@ -36,7 +36,7 @@ RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts`
平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`。弹窗必须带明确错误来源,例如某个草稿、某次生成、作品详情或某个游玩实例,并提供复制按钮复制“错误来源 + 错误内容”。页面内不再重复渲染裸错误 banner表单校验、发布确认弹窗里的局部业务错误可以保留在原弹窗内。生成任务在用户离开生成页后异步失败时也必须通过同一弹窗通知用户并把失败消息写入该 session 的草稿 notice供草稿页和失败重试页恢复使用。
生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。
生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。外部生成队列的用户可见概览统一放在移动端一级 `我的` 页签,生成页 / 进度页只展示当前玩法的阶段、步骤、总进度、错误和重试动作;用户离开生成页后仍可在 `我的` 页查看当前账号可见的排队与生成数量。队列概览只作为等待状态补充,草稿 ready / failed 与作品结果仍以后端玩法 session/detail 回读为准。
入口配置中的 `open=false` 表示关闭新建创作入口不表示下架已有草稿、私有作品或公开作品。api-server 的入口熔断只允许拦截新建创作、新建草稿、首次生成入口和 Remix 成草稿等会产生新创作的请求;公开广场列表、公开详情、点赞、已发布作品启动、运行态过程请求、存档 / 浏览记录和已有作品回读不能因为创作入口关闭而返回 `creation_entry_disabled`。平台首页如果遇到旧服务端返回的 `creation_entry_disabled`,只能降级为空列表或隐藏入口,不弹平台级错误弹窗。
@@ -148,6 +148,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
- 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回、设置和下一关的点击容器只提供透明点击区,不再叠加默认白色圆形底、胶囊主按钮底或额外文字;下一关按钮在通关弹窗和底部入口中都直接使用 spritesheet 裁切出的 next 素材作为按钮本体。底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。
- 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,正式 runtime 启动与后续局内动作继续走账号 Bearer只有确认为匿名访客时才申请并透传 runtime guest token。平台壳统一通过 `buildRecommendRuntimeRequestOptions(...)` 为各玩法的 start / checkpoint / finish / input / drop / click / restart / time-up / leaderboard / next-level 等动作生成局部 request options不允许每个玩法各写一套匿名分支。后端 `/api/runtime/*` 正式运行态写请求统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest推荐卡片的后台读写请求仍使用 local auth impact避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。
- 推荐页作品队列只能通过 `buildPlatformRecommendFeedEntries(...)` 生成,首页卡片窗口、桌面推荐格、嵌入 runtime 自动启动和上一条 / 下一条切换都必须消费同一队列。不得在首页和 `PlatformEntryFlowShellImpl` 内分别按“最新列表顺序”和“评分推荐顺序”各算一套相邻作品,否则连续切换会出现视觉上跳过作品或回跳。
- 推荐页作品信息区的分享按钮统一唤起发布分享弹窗 `PublishShareModal`,不在推荐卡内部单独拼接分享文案或只做剪贴板复制反馈;拼图推荐作品的 H5 分享链接继续沿用 `/gallery/puzzle/detail?work=...`,其它统一公开作品默认走 `/works/detail?work=...`。微信小程序 WebView 内复制动作必须改为小程序 `pages/web-view/index` 路径并补齐 `targetPath=/works/detail``work` 参数。推荐页当前 active 作品必须通过 `wx.miniProgram.postMessage` 同步给原生 `web-view` 页,让右上角系统“转发给朋友”和“分享到朋友圈”也使用当前作品参数生成小程序短链背后的 path。微信小程序 WebView 内的推荐页运行态需要启用分享快照安全区,把游戏画面等比缩放并保持在页面中部,避免用户直接点击小程序自带“分享到聊天”时只截到游戏画面局部。
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。

View File

@@ -121,9 +121,9 @@ 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` 是当前领域语言入口;架构决策以本文档和 `.hermes/shared-memory/decision-log.md` 的最新稳定摘要为准。
-`CONTEXT.md` 是当前领域语言入口;架构决策以本文档和 `docs/project-memory/shared-memory/decision-log.md` 的最新稳定摘要为准。
- `.hermes/` 只保存可进入 Git 的团队共享记忆、计划和可公开 skill不提交个人 Hermes 配置、会话、密钥、Token 或本地私密路径。
- 每次工程修改都应同步更新本目录当前文档;如果产生长期有效知识,再同步 `.hermes/shared-memory/`
- 每次工程修改都应同步更新本目录当前文档;如果产生长期有效知识,再同步 `docs/project-memory/shared-memory/`
## 当前文档策略

View File

@@ -21,7 +21,7 @@ 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.4.1', description: '目标服务器使用的 SpacetimeDB Linux release tarball 根地址;默认固定到项目锁定版本')
string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.5.0', description: '目标服务器使用的 SpacetimeDB Linux release tarball 根地址;默认固定到项目锁定版本')
string(name: 'SPACETIME_TARGET_HOST', defaultValue: 'x86_64-unknown-linux-gnu', description: 'SpacetimeDB 预编译包 host tripledevelopment/release Linux amd64 使用默认值')
string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir')
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录')
@@ -162,7 +162,7 @@ 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.4.1}" \
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.5.0}" \
SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" \
SPACETIME_ROOT="${SPACETIME_ROOT:-/stdb}" \
scripts/prepare-server-provision-tools.sh

View File

@@ -1,6 +1,7 @@
{
"pages": [
"pages/web-view/index",
"pages/share-grid/index",
"pages/wechat-pay/index",
"pages/subscribe-message/index"
],

View File

@@ -0,0 +1,206 @@
/* global Page, wx */
/* eslint-disable no-console */
const {
buildShareGridTileFileName,
buildShareGridTilePlan,
normalizeShareGridQuery,
} = require('./index.shared');
function downloadImage(imageUrl) {
return new Promise((resolve, reject) => {
wx.downloadFile({
url: imageUrl,
success(response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(response.tempFilePath);
return;
}
reject(new Error(`封面下载失败:${response.statusCode}`));
},
fail(error) {
reject(new Error(error.errMsg || '封面下载失败'));
},
});
});
}
function getImageInfo(src) {
return new Promise((resolve, reject) => {
wx.getImageInfo({
src,
success: resolve,
fail(error) {
reject(new Error(error.errMsg || '读取封面失败'));
},
});
});
}
function getCanvasNode(page) {
return new Promise((resolve, reject) => {
wx.createSelectorQuery()
.in(page)
.select('#share-grid-canvas')
.fields({ node: true, size: true })
.exec((results) => {
const canvas = results && results[0] && results[0].node;
if (canvas) {
resolve(canvas);
return;
}
reject(new Error('切图画布初始化失败'));
});
});
}
function canvasToTempFilePath(canvas, width, height) {
return new Promise((resolve, reject) => {
wx.canvasToTempFilePath({
canvas,
width,
height,
destWidth: width,
destHeight: height,
fileType: 'png',
success(response) {
resolve(response.tempFilePath);
},
fail(error) {
reject(new Error(error.errMsg || '导出切图失败'));
},
});
});
}
function saveImageToAlbum(filePath) {
return new Promise((resolve, reject) => {
wx.saveImageToPhotosAlbum({
filePath,
success() {
resolve();
},
fail(error) {
reject(new Error(error.errMsg || '保存到相册失败'));
},
});
});
}
function copyTempFileWithName(tempFilePath, fileName) {
const fileSystem = wx.getFileSystemManager && wx.getFileSystemManager();
const userDataPath = wx.env && wx.env.USER_DATA_PATH;
if (!fileSystem || !userDataPath || typeof fileSystem.copyFile !== 'function') {
return Promise.resolve(tempFilePath);
}
const targetPath = `${userDataPath}/${fileName}`;
return new Promise((resolve) => {
fileSystem.copyFile({
srcPath: tempFilePath,
destPath: targetPath,
success() {
resolve(targetPath);
},
fail() {
resolve(tempFilePath);
},
});
});
}
async function saveGridTiles(page, params, localImagePath, imageInfo) {
const canvas = await getCanvasNode(page);
const context = canvas.getContext('2d');
const image = canvas.createImage();
await new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = () => reject(new Error('封面绘制失败'));
image.src = localImagePath;
});
const plan = buildShareGridTilePlan(imageInfo.width, imageInfo.height);
for (const tile of plan) {
canvas.width = tile.sourceWidth;
canvas.height = tile.sourceHeight;
context.clearRect(0, 0, tile.sourceWidth, tile.sourceHeight);
context.drawImage(
image,
tile.sourceX,
tile.sourceY,
tile.sourceWidth,
tile.sourceHeight,
0,
0,
tile.sourceWidth,
tile.sourceHeight,
);
const tempFilePath = await canvasToTempFilePath(
canvas,
tile.sourceWidth,
tile.sourceHeight,
);
const namedFilePath = await copyTempFileWithName(
tempFilePath,
buildShareGridTileFileName(params, tile.index),
);
await saveImageToAlbum(namedFilePath);
page.setData({
savedCount: tile.index + 1,
});
}
}
Page({
data: {
errorMessage: '',
loading: true,
savedCount: 0,
title: '九宫切图',
},
async onLoad(query = {}) {
const params = normalizeShareGridQuery(query);
this._shareGridParams = params;
this.setData({
errorMessage: '',
loading: true,
savedCount: 0,
title: params.title,
});
if (!params.imageUrl) {
this.setData({
errorMessage: '缺少封面图。',
loading: false,
});
return;
}
try {
const localImagePath = await downloadImage(params.imageUrl);
const imageInfo = await getImageInfo(localImagePath);
await saveGridTiles(this, params, localImagePath, imageInfo);
this.setData({
loading: false,
savedCount: 9,
});
wx.showToast({
title: '已保存',
icon: 'success',
});
} catch (error) {
console.error('[share-grid] save failed', error);
this.setData({
errorMessage:
error && error.message ? error.message : '九宫切图保存失败。',
loading: false,
});
}
},
handleBack() {
wx.navigateBack();
},
});

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "九宫切图"
}

View File

@@ -0,0 +1,62 @@
const GRID_SIZE = 3;
const TILE_COUNT = GRID_SIZE * GRID_SIZE;
function normalizeQueryValue(value) {
return String(value || '').trim();
}
function sanitizeFileNamePart(value) {
const normalized = normalizeQueryValue(value)
.replace(/[\\/:*?"<>|]/g, '')
.replace(/\s+/g, '-')
.slice(0, 32);
return normalized || 'taonier';
}
function buildShareGridTileFileName(params, tileIndex) {
const safeTitle = sanitizeFileNamePart(params.title || params.publicWorkCode);
const safeCode = sanitizeFileNamePart(params.publicWorkCode || 'share');
const order = String(tileIndex + 1).padStart(2, '0');
return `${safeTitle}-${safeCode}-${order}.png`;
}
function normalizeShareGridQuery(query) {
return {
imageUrl: normalizeQueryValue(query && query.imageUrl),
title: normalizeQueryValue(query && query.title) || '我的作品',
publicWorkCode: normalizeQueryValue(query && query.publicWorkCode),
};
}
function buildShareGridTilePlan(imageWidth, imageHeight) {
const tileWidth = Math.floor(imageWidth / GRID_SIZE);
const tileHeight = Math.floor(imageHeight / GRID_SIZE);
const plan = [];
for (let row = 0; row < GRID_SIZE; row += 1) {
for (let col = 0; col < GRID_SIZE; col += 1) {
const index = row * GRID_SIZE + col;
const sourceX = col * tileWidth;
const sourceY = row * tileHeight;
plan.push({
index,
row,
col,
sourceX,
sourceY,
sourceWidth: col === GRID_SIZE - 1 ? imageWidth - sourceX : tileWidth,
sourceHeight: row === GRID_SIZE - 1 ? imageHeight - sourceY : tileHeight,
});
}
}
return plan;
}
module.exports = {
GRID_SIZE,
TILE_COUNT,
buildShareGridTileFileName,
buildShareGridTilePlan,
normalizeShareGridQuery,
};

View File

@@ -0,0 +1,67 @@
import { describe, expect, test } from 'vitest';
import shareGridBridge from './index.shared.js';
const {
buildShareGridTileFileName,
buildShareGridTilePlan,
normalizeShareGridQuery,
} = shareGridBridge;
describe('share-grid mini program bridge', () => {
test('normalizes query values and keeps a fallback title', () => {
expect(
normalizeShareGridQuery({
imageUrl: ' https://web.test/cover.png ',
publicWorkCode: ' PZ-0001 ',
}),
).toEqual({
imageUrl: 'https://web.test/cover.png',
title: '我的作品',
publicWorkCode: 'PZ-0001',
});
});
test('names tiles by title, public code and left-to-right order', () => {
const params = {
title: '星港:拼图',
publicWorkCode: 'PZ-0001',
};
expect(buildShareGridTileFileName(params, 0)).toBe(
'星港拼图-PZ-0001-01.png',
);
expect(buildShareGridTileFileName(params, 8)).toBe(
'星港拼图-PZ-0001-09.png',
);
});
test('builds a 3x3 crop plan in reading order', () => {
const plan = buildShareGridTilePlan(900, 600);
expect(plan).toHaveLength(9);
expect(plan[0]).toMatchObject({
index: 0,
row: 0,
col: 0,
sourceX: 0,
sourceY: 0,
sourceWidth: 300,
sourceHeight: 200,
});
expect(plan[4]).toMatchObject({
index: 4,
row: 1,
col: 1,
sourceX: 300,
sourceY: 200,
});
expect(plan[8]).toMatchObject({
index: 8,
row: 2,
col: 2,
sourceX: 600,
sourceY: 400,
});
});
});

View File

@@ -0,0 +1,20 @@
<view class="share-grid-page">
<view class="share-grid-card">
<view class="share-grid-title">{{title}}</view>
<view wx:if="{{loading}}" class="share-grid-text">
正在保存 {{savedCount}}/9
</view>
<view wx:elif="{{errorMessage}}" class="share-grid-text share-grid-text--danger">
{{errorMessage}}
</view>
<view wx:else class="share-grid-text">已保存 9/9</view>
<button class="share-grid-button" bindtap="handleBack">
返回
</button>
</view>
<canvas
id="share-grid-canvas"
type="2d"
class="share-grid-canvas"
></canvas>
</view>

View File

@@ -0,0 +1,60 @@
page {
background: #fffdf9;
}
.share-grid-page {
min-height: 100vh;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx;
background: #fffdf9;
}
.share-grid-card {
width: 100%;
max-width: 560rpx;
box-sizing: border-box;
border: 1rpx solid rgba(127, 85, 57, 0.18);
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.92);
padding: 36rpx;
box-shadow: 0 24rpx 68rpx rgba(127, 85, 57, 0.12);
}
.share-grid-title {
color: #332820;
font-size: 34rpx;
font-weight: 700;
line-height: 1.35;
}
.share-grid-text {
margin-top: 18rpx;
color: rgba(51, 40, 32, 0.68);
font-size: 26rpx;
line-height: 1.55;
}
.share-grid-text--danger {
color: #b84a3d;
}
.share-grid-button {
margin-top: 28rpx;
width: 100%;
border-radius: 8rpx;
background: #7f5539;
color: #fffdf9;
font-size: 28rpx;
line-height: 2.6;
}
.share-grid-canvas {
position: fixed;
left: -9999px;
top: -9999px;
width: 1px;
height: 1px;
}

View File

@@ -10,6 +10,13 @@ const {
WEB_VIEW_ENTRY_URL,
WEB_VIEW_SOURCE_QUERY,
} = require('../../config');
const {
appendHashParams,
buildWebViewSharePath,
buildWebViewShareTimelineQuery,
resolveShareTargetFromWebViewMessage,
resolveWebViewUrlFromRuntimeConfig,
} = require('./index.shared');
const MINI_PROGRAM_CLIENT_TYPE = 'mini_program';
const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program';
@@ -19,7 +26,6 @@ const AUTH_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result';
const AUTH_ACTION_LOGIN = 'login';
const PAY_RESULT_RECHECK_DELAY_MS = 120;
const WEB_VIEW_SHARE_TITLE = '陶泥儿';
const WEB_VIEW_SHARE_PATH = '/pages/web-view/index';
function showWebViewShareMenu() {
if (typeof wx.showShareMenu !== 'function') {
@@ -32,17 +38,25 @@ function showWebViewShareMenu() {
});
}
function buildWebViewShareAppMessage() {
function resolveNativeShareQuery(page) {
return (
(page && page._currentShareTarget) ||
(page && page._lastLaunchQuery) ||
{}
);
}
function buildWebViewShareAppMessage(query = {}) {
return {
title: WEB_VIEW_SHARE_TITLE,
path: WEB_VIEW_SHARE_PATH,
path: buildWebViewSharePath(query),
};
}
function buildWebViewShareTimeline() {
function buildWebViewShareTimeline(query = {}) {
return {
title: WEB_VIEW_SHARE_TITLE,
query: '',
query: buildWebViewShareTimelineQuery(query),
};
}
@@ -59,50 +73,6 @@ function isConfiguredApiBaseUrl(value) {
return /^https:\/\/[^/]+/i.test(String(value || '').trim());
}
function appendQuery(url, query) {
const pairs = Object.keys(query)
.filter((key) => query[key])
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`,
);
if (pairs.length === 0) {
return url;
}
return `${url}${url.includes('?') ? '&' : '?'}${pairs.join('&')}`;
}
function appendHashParams(url, params) {
const nextKeys = new Set(Object.keys(params).filter((key) => params[key]));
const pairs = Object.keys(params)
.filter((key) => params[key])
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`,
);
if (pairs.length === 0) {
return url;
}
const hashIndex = url.indexOf('#');
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : '';
const keptHashParts = rawHash.split('&').filter((part) => {
if (!part) {
return false;
}
const [rawKey = ''] = part.split('=');
try {
return !nextKeys.has(decodeURIComponent(rawKey));
} catch (_error) {
return !nextKeys.has(rawKey);
}
});
return `${baseUrl}#${keptHashParts.concat(pairs).join('&')}`;
}
function parseBooleanQueryFlag(value) {
return value === true || value === '1' || value === 'true' || value === 'yes';
}
@@ -233,22 +203,16 @@ function shouldReturnToPreviousPage(query) {
return String((query && query.returnTo) || '').trim() === 'previous';
}
function resolveWebViewUrl(authResult) {
function resolveWebViewUrl(authResult, launchQuery = {}) {
const runtimeConfig = resolveMiniProgramRuntimeConfig();
const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim();
if (!isConfiguredEntryUrl(entryUrl)) {
return '';
}
const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery);
if (!authResult || !authResult.token) {
return sourcedUrl;
}
return appendHashParams(sourcedUrl, {
auth_provider: 'wechat',
auth_token: authResult.token,
auth_binding_status: authResult.bindingStatus,
return resolveWebViewUrlFromRuntimeConfig(authResult, launchQuery, {
...runtimeConfig,
webViewEntryUrl: String(runtimeConfig.webViewEntryUrl || '').trim(),
});
}
@@ -467,7 +431,7 @@ Page({
loading: false,
phoneBindingRequired: false,
returnToPreviousPage: false,
webViewUrl: resolveWebViewUrl(null),
webViewUrl: resolveWebViewUrl(null, query),
});
return;
}
@@ -572,7 +536,7 @@ Page({
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage,
webViewUrl: resolveWebViewUrl(authResult),
webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}),
});
} catch (error) {
this.setData({
@@ -600,7 +564,7 @@ Page({
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
webViewUrl: resolveWebViewUrl(authResult),
webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}),
});
}
@@ -674,7 +638,10 @@ Page({
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
webViewUrl: resolveWebViewUrl(nextAuthResult),
webViewUrl: resolveWebViewUrl(
nextAuthResult,
this._lastLaunchQuery || {},
),
});
} catch (error) {
this.setData({
@@ -712,15 +679,19 @@ Page({
},
handleWebViewMessage(event) {
const shareTarget = resolveShareTargetFromWebViewMessage(event.detail);
if (shareTarget) {
this._currentShareTarget = shareTarget;
}
// 中文注释:支付和订阅消息都由独立 native 页面承接web-view 消息只保留调试输出。
console.info('[web-view] message', event.detail);
},
onShareAppMessage() {
return buildWebViewShareAppMessage();
return buildWebViewShareAppMessage(resolveNativeShareQuery(this));
},
onShareTimeline() {
return buildWebViewShareTimeline();
return buildWebViewShareTimeline(resolveNativeShareQuery(this));
},
});

View File

@@ -0,0 +1,188 @@
const ALLOWED_TARGET_PATHS = new Set(['/works/detail']);
const SHARE_TARGET_MESSAGE_TYPE = 'genarrative:share-target';
const WEB_VIEW_SHARE_PATH = '/pages/web-view/index';
function trimTrailingSlash(value) {
return String(value || '').trim().replace(/\/+$/u, '');
}
function appendQuery(url, query) {
const rawUrl = String(url || '');
const pairs = Object.keys(query)
.filter((key) => query[key])
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`,
);
if (pairs.length === 0) {
return rawUrl;
}
const hashIndex = rawUrl.indexOf('#');
const baseUrl = hashIndex >= 0 ? rawUrl.slice(0, hashIndex) : rawUrl;
const hash = hashIndex >= 0 ? rawUrl.slice(hashIndex) : '';
return `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}${pairs.join('&')}${hash}`;
}
function appendHashParams(url, params) {
const nextKeys = new Set(Object.keys(params).filter((key) => params[key]));
const pairs = Object.keys(params)
.filter((key) => params[key])
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`,
);
if (pairs.length === 0) {
return url;
}
const hashIndex = url.indexOf('#');
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : '';
const keptHashParts = rawHash.split('&').filter((part) => {
if (!part) {
return false;
}
const [rawKey = ''] = part.split('=');
try {
return !nextKeys.has(decodeURIComponent(rawKey));
} catch (_error) {
return !nextKeys.has(rawKey);
}
});
return `${baseUrl}#${keptHashParts.concat(pairs).join('&')}`;
}
function normalizeTargetPath(value) {
const trimmed = String(value || '').trim();
if (!trimmed.startsWith('/')) {
return '';
}
const normalized = trimmed.replace(/\/+$/u, '') || '/';
return ALLOWED_TARGET_PATHS.has(normalized) ? normalized : '';
}
function resolveLaunchTargetQuery(query) {
const targetPath = normalizeTargetPath(query && query.targetPath);
const work = String((query && query.work) || '').trim();
if (!targetPath || !work) {
return {};
}
return {
targetPath,
work,
};
}
function buildWebViewSharePath(query = {}, basePath = WEB_VIEW_SHARE_PATH) {
const launchTarget = resolveLaunchTargetQuery(query);
if (!launchTarget.targetPath) {
return basePath;
}
return appendQuery(basePath, {
targetPath: launchTarget.targetPath,
work: launchTarget.work,
});
}
function buildWebViewShareTimelineQuery(query = {}) {
const launchTarget = resolveLaunchTargetQuery(query);
if (!launchTarget.targetPath) {
return '';
}
return new URLSearchParams({
targetPath: launchTarget.targetPath,
work: launchTarget.work,
}).toString();
}
function normalizeShareTargetMessageData(value) {
const message = value && value.data ? value.data : value;
if (!message || message.type !== SHARE_TARGET_MESSAGE_TYPE) {
return null;
}
const payload = message.payload || {};
const launchTarget = resolveLaunchTargetQuery(payload);
if (!launchTarget.targetPath) {
return null;
}
return {
...launchTarget,
title: String(payload.title || '').trim(),
};
}
function resolveShareTargetFromWebViewMessage(detail) {
const dataList = detail && Array.isArray(detail.data) ? detail.data : [];
for (let index = dataList.length - 1; index >= 0; index -= 1) {
const target = normalizeShareTargetMessageData(dataList[index]);
if (target) {
return target;
}
}
return normalizeShareTargetMessageData(detail);
}
function appendLaunchTargetToEntryUrl(entryUrl, query) {
const launchTarget = resolveLaunchTargetQuery(query);
if (!launchTarget.targetPath) {
return entryUrl;
}
const rawEntryUrl = String(entryUrl || '').trim();
const hashIndex = rawEntryUrl.indexOf('#');
const entryWithoutHash =
hashIndex >= 0 ? rawEntryUrl.slice(0, hashIndex) : rawEntryUrl;
const hash = hashIndex >= 0 ? rawEntryUrl.slice(hashIndex) : '';
const queryIndex = entryWithoutHash.indexOf('?');
const entryBase =
queryIndex >= 0 ? entryWithoutHash.slice(0, queryIndex) : entryWithoutHash;
const entrySearch =
queryIndex >= 0 ? entryWithoutHash.slice(queryIndex) : '';
const targetUrl = `${trimTrailingSlash(entryBase)}${launchTarget.targetPath}${entrySearch}${hash}`;
return appendQuery(targetUrl, {
work: launchTarget.work,
});
}
function resolveWebViewUrlFromRuntimeConfig(
authResult,
launchQuery = {},
runtimeConfig = {},
) {
const entryUrl = appendLaunchTargetToEntryUrl(
String(runtimeConfig.webViewEntryUrl || '').trim(),
launchQuery,
);
const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery || {});
if (!authResult || !authResult.token) {
return sourcedUrl;
}
return appendHashParams(sourcedUrl, {
auth_provider: 'wechat',
auth_token: authResult.token,
auth_binding_status: authResult.bindingStatus,
});
}
module.exports = {
appendHashParams,
appendLaunchTargetToEntryUrl,
appendQuery,
buildWebViewSharePath,
buildWebViewShareTimelineQuery,
normalizeTargetPath,
resolveShareTargetFromWebViewMessage,
resolveLaunchTargetQuery,
resolveWebViewUrlFromRuntimeConfig,
};

View File

@@ -0,0 +1,110 @@
import { describe, expect, test } from 'vitest';
import webViewBridge from './index.shared.js';
const {
appendLaunchTargetToEntryUrl,
buildWebViewSharePath,
buildWebViewShareTimelineQuery,
resolveShareTargetFromWebViewMessage,
resolveWebViewUrlFromRuntimeConfig,
} = webViewBridge;
const runtimeConfig = {
sourceQuery: {
clientType: 'mini_program',
clientRuntime: 'wechat_mini_program',
},
webViewEntryUrl: 'https://www.genarrative.world',
};
describe('mini program web-view launch target', () => {
test('opens the H5 public work detail when launch query carries work params', () => {
expect(
appendLaunchTargetToEntryUrl('https://www.genarrative.world?foo=bar', {
targetPath: '/works/detail',
work: 'BB-12345678',
}),
).toBe(
'https://www.genarrative.world/works/detail?foo=bar&work=BB-12345678',
);
const webViewUrl = resolveWebViewUrlFromRuntimeConfig(
null,
{
targetPath: '/works/detail',
work: 'BB-12345678',
},
runtimeConfig,
);
const url = new URL(webViewUrl);
expect(url.pathname).toBe('/works/detail');
expect(url.searchParams.get('work')).toBe('BB-12345678');
expect(url.searchParams.get('clientRuntime')).toBe('wechat_mini_program');
});
test('ignores unsupported launch target paths', () => {
const webViewUrl = resolveWebViewUrlFromRuntimeConfig(
null,
{
targetPath: '/admin',
work: 'BB-12345678',
},
runtimeConfig,
);
const url = new URL(webViewUrl);
expect(url.pathname).toBe('/');
expect(url.searchParams.get('work')).toBeNull();
});
test('keeps public work params in native mini program share paths', () => {
const sharePath = buildWebViewSharePath({
targetPath: '/works/detail',
work: 'BB-12345678',
});
const url = new URL(sharePath, 'https://mini.test');
expect(url.pathname).toBe('/pages/web-view/index');
expect(url.searchParams.get('targetPath')).toBe('/works/detail');
expect(url.searchParams.get('work')).toBe('BB-12345678');
expect(
buildWebViewShareTimelineQuery({
targetPath: '/works/detail',
work: 'BB-12345678',
}),
).toBe('targetPath=%2Fworks%2Fdetail&work=BB-12345678');
});
test('reads the latest H5 recommended work share target from web-view messages', () => {
expect(
resolveShareTargetFromWebViewMessage({
data: [
{
data: {
type: 'genarrative:share-target',
payload: {
targetPath: '/works/detail',
work: 'PZ-0001',
title: '旧作品',
},
},
},
{
data: {
type: 'genarrative:share-target',
payload: {
targetPath: '/works/detail',
work: 'BB-12345678',
title: '汪汪声浪',
},
},
},
],
}),
).toEqual({
targetPath: '/works/detail',
work: 'BB-12345678',
title: '汪汪声浪',
});
});
});

View File

@@ -55,6 +55,7 @@
"container:ps": "node scripts/container-compose.mjs ps",
"container:config": "node scripts/container-compose.mjs config",
"container:k6": "node scripts/container-compose.mjs k6",
"container:worker-smoke": "node scripts/container-worker-smoke.mjs",
"check": "npm run lint && npm run test && npm run build && npm run check:content",
"check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts",
"check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts",
@@ -64,6 +65,8 @@
"codegraph:index": "codegraph index .",
"codegraph:sync": "codegraph sync .",
"codegraph:status": "codegraph status .",
"rag:index": "node scripts/rag/index-docs.mjs",
"rag:search": "node scripts/rag/search-docs.mjs",
"database:backup:oss": "node scripts/database-backup-to-oss.mjs"
},
"dependencies": {

View File

@@ -0,0 +1,29 @@
export type ExternalGenerationJobStatus =
| 'queued'
| 'running'
| 'completed'
| 'failed';
export interface ExternalGenerationQueueOverview {
pendingCount: number;
runningCount: number;
updatedAtMicros: number;
}
export interface ExternalGenerationQueueOverviewResponse {
overview: ExternalGenerationQueueOverview;
}
export interface ExternalGenerationJobStatusRecord {
operationId: string;
status: ExternalGenerationJobStatus;
phaseLabel: string;
phaseDetail: string;
progress: number;
error?: string | null;
updatedAtMicros: number;
}
export interface ExternalGenerationJobStatusResponse {
job: ExternalGenerationJobStatusRecord;
}

View File

@@ -1,3 +1,5 @@
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
export type JumpHopDifficulty = 'easy' | 'standard' | 'advanced' | 'challenge';
export type JumpHopStylePreset =
@@ -206,6 +208,7 @@ export interface JumpHopActionResponse {
actionType: JumpHopActionType;
session: JumpHopSessionSnapshotResponse;
work: JumpHopWorkProfileResponse | null;
queueState?: ExternalGenerationJobStatusRecord | null;
}
export interface JumpHopWorkSummaryResponse {

View File

@@ -1,4 +1,5 @@
import type { PuzzleAgentSessionSnapshot } from './puzzleAgentSession';
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
export type PuzzleAgentSuggestedActionType =
| 'request_summary'
@@ -41,6 +42,7 @@ export interface PuzzleAgentOperationRecord {
phaseDetail: string;
progress: number;
error?: string | null;
queueState?: ExternalGenerationJobStatusRecord | null;
}
export type PuzzleAgentActionRequest =

View File

@@ -1,3 +1,5 @@
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
export type PuzzleClearGenerationStatus = 'draft' | 'generating' | 'ready' | 'failed';
export type PuzzleClearShapeKind = '1x2' | '1x3' | '2x2' | '2x3';
@@ -109,6 +111,7 @@ export interface PuzzleClearActionResponse {
actionType: PuzzleClearActionType;
session: PuzzleClearSessionSnapshotResponse;
work: PuzzleClearWorkProfileResponse | null;
queueState?: ExternalGenerationJobStatusRecord | null;
}
export interface PuzzleClearWorkSummaryResponse {

View File

@@ -1,3 +1,5 @@
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
export type WoodenFishGenerationStatus =
| 'draft'
| 'generating'
@@ -104,6 +106,7 @@ export interface WoodenFishActionResponse {
actionType: WoodenFishActionType;
session: WoodenFishSessionSnapshotResponse;
work: WoodenFishWorkProfileResponse | null;
queueState?: ExternalGenerationJobStatusRecord | null;
}
export interface WoodenFishWorkSummaryResponse {

View File

@@ -1,4 +1,4 @@
export const API_VERSION = '2026-04-08';
export const API_VERSION = '2026-06-16';
export const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
export const API_RESPONSE_ENVELOPE_VERSION = 'v1';

View File

@@ -8,6 +8,7 @@ export type * from './contracts/creativeAgent';
export type * from './contracts/customWorldAgent';
export * from './contracts/edutainmentBabyDrawing';
export * from './contracts/edutainmentBabyObject';
export * from './contracts/externalGeneration';
export type * from './contracts/hyper3d';
export * from './contracts/match3dAgent';
export * from './contracts/match3dRuntime';

View File

@@ -23,6 +23,46 @@ const checks = [
includes: 'genarrative-health-patrol.timer',
reason: 'Server-Provision 必须安装并启用健康巡检 timer。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'genarrative-external-generation-controller.service',
reason: 'Server-Provision 必须安装并启用外部生成 worker controller。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'genarrative-external-generation-worker@1.service',
reason: 'Server-Provision 必须启用外部生成保底 worker 实例。',
},
{
file: 'scripts/deploy/production-api-deploy.sh',
includes: 'ensure_default_worker_service',
reason: 'API Deploy 必须在缺少 worker 实例时补启动默认外部生成 worker。',
},
{
file: 'scripts/deploy/production-api-deploy.sh',
includes: 'wait_for_worker_services',
reason: 'API Deploy 必须等待外部生成 worker 实例 active。',
},
{
file: 'scripts/deploy/production-api-deploy.sh',
includes: 'wait_for_worker_controller_service',
reason: 'API Deploy 必须重启并验活外部生成 worker controller。',
},
{
file: 'deploy/systemd/genarrative-external-generation-worker@.service',
includes: 'GENARRATIVE_PROCESS_ROLE=external-generation-worker',
reason: '外部生成 worker 模板必须作为独立 worker 进程角色运行。',
},
{
file: 'deploy/systemd/genarrative-external-generation-controller.service',
includes: 'GENARRATIVE_PROCESS_ROLE=external-generation-controller',
reason: '外部生成 worker controller 必须作为独立进程角色运行。',
},
{
file: 'scripts/ops/production-health-patrol.mjs',
includes: 'checkActiveWorkerInstances',
reason: '生产健康巡检必须检查至少一个外部生成 worker 实例 active。',
},
{
file: 'scripts/build-production-release.sh',
includes: 'production-health-patrol.mjs',

View File

@@ -37,11 +37,11 @@ chmod +x "${TARGET_BIN_DIR}/otelcol-contrib"
cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" <<'EOF'
#!/usr/bin/env bash
echo "spacetimedb-cli 2.4.1"
echo "spacetimedb-cli 2.5.0"
EOF
cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-standalone" <<'EOF'
#!/usr/bin/env bash
echo "spacetimedb-standalone 2.4.1"
echo "spacetimedb-standalone 2.5.0"
EOF
chmod +x \
"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" \
@@ -58,7 +58,7 @@ if ! (
OTELCOL_TARGET_BIN="${TARGET_BIN_DIR}/otelcol-contrib" \
OTELCOL_VERSION="0.151.0" \
SPACETIME_ROOT="${SPACETIME_ROOT_DIR}" \
SPACETIME_EXPECTED_VERSION="2.4.1" \
SPACETIME_EXPECTED_VERSION="2.5.0" \
"${REPO_ROOT}/scripts/prepare-server-provision-tools.sh" \
>"${OUTPUT_LOG}" 2>&1
); then

View File

@@ -475,14 +475,14 @@ function loadBaseSources(baseRef) {
function getChangedFiles(baseRef) {
const diffOutput = tryGit(['diff', '--name-only', '-z', baseRef, '--']) ?? '';
const untrackedOutput =
const untrackedModuleOutput =
tryGit(['ls-files', '--others', '--exclude-standard', '-z', moduleSrcRoot]) ?? '';
const untrackedBindingsOutput =
tryGit(['ls-files', '--others', '--exclude-standard', '-z', bindingsRoot]) ?? '';
return new Set(
[
...diffOutput.split(/\u0000/u),
...untrackedOutput.split(/\u0000/u),
...untrackedModuleOutput.split(/\u0000/u),
...untrackedBindingsOutput.split(/\u0000/u),
]
.map(normalizePath)

View File

@@ -7,7 +7,7 @@ const reportPath = join(repoRoot, '.tmp', 'VN11_NEGATIVE_SCAN_REPORT_2026-05-07.
const documentTargets = [
'docs',
'.hermes/shared-memory',
'docs/project-memory/shared-memory',
];
const visualNovelImplementationTargets = [
@@ -202,7 +202,7 @@ const reportLines = [
'## 扫描范围',
'',
'- 视觉小说工程代码视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径',
'- 文档与共享记忆:`docs/`、`.hermes/shared-memory/`',
'- 文档与共享记忆:`docs/`、`docs/project-memory/shared-memory/`',
'- 外部平台误入复核视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径',
'',
'## 扫描结论',

View File

@@ -0,0 +1,839 @@
import {spawn} from 'node:child_process';
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import net from 'node:net';
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 smokeDir = path.join('deploy', 'container', 'worker-smoke');
const envPath = path.join(smokeDir, 'api-server.env');
const statePath = path.join(smokeDir, 'state.json');
const localImageDir = path.join(smokeDir, 'image');
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 localSpacetimeBinaryPath = path.join(localSpacetimeImageDir, 'spacetime');
const localSpacetimeStandalonePath = path.join(
localSpacetimeImageDir,
'spacetimedb-standalone',
);
const projectName = process.env.GENARRATIVE_WORKER_SMOKE_PROJECT || 'genarrative-worker-smoke';
const defaultDatabase =
process.env.GENARRATIVE_WORKER_SMOKE_DATABASE || 'genarrative-worker-smoke';
const command = rawCommand.trim();
const supportedCommands = new Set([
'help',
'init',
'build',
'up-spacetime',
'publish',
'up',
'enqueue',
'status',
'api-update',
'scale',
'logs',
'ps',
'down',
'smoke',
]);
if (!supportedCommands.has(command)) {
printHelp(true);
process.exit(1);
}
try {
await main();
} catch (error) {
console.error(`[worker-smoke] ${error.message}`);
process.exit(1);
}
async function main() {
switch (command) {
case 'help':
printHelp(false);
return;
case 'init':
await ensureStateAndEnv({force: rawArgs.includes('--force')});
return;
case 'build':
await ensureStateAndEnv();
await buildRuntimeImages();
return;
case 'up-spacetime':
await ensureStateAndEnv();
await ensureSpacetimeImage();
await dockerCompose(['up', '-d', 'spacetimedb', 'otelcol']);
await waitForSpacetime();
return;
case 'publish':
await ensureStateAndEnv();
await publishModule();
return;
case 'up':
await ensureStateAndEnv();
await upRuntime();
await waitForApi();
return;
case 'enqueue':
await ensureStateAndEnv();
await enqueueSmokeJob();
return;
case 'status':
await ensureStateAndEnv();
await printQueueStatus();
return;
case 'api-update':
await ensureStateAndEnv();
await apiOnlyUpdate({build: rawArgs.includes('--build')});
return;
case 'scale':
await ensureStateAndEnv();
await scaleWorkers(rawArgs[0] ?? '1');
return;
case 'logs':
await ensureStateAndEnv();
await dockerCompose(['logs', ...rawArgs]);
return;
case 'ps':
await ensureStateAndEnv();
await dockerCompose(['ps', ...rawArgs]);
return;
case 'down':
await ensureStateAndEnv({create: false});
await dockerCompose(['down', ...rawArgs]);
return;
case 'smoke':
await runSmoke();
return;
default:
throw new Error(`未知命令: ${command}`);
}
}
async function runSmoke() {
if (rawArgs.includes('--force')) {
await ensureStateAndEnv();
await dockerComposeCapture(['down', '-v'], {allowFailure: true});
}
const state = await ensureStateAndEnv({force: rawArgs.includes('--force')});
await assertSavedPortsAvailableForNewProject(state);
console.log(
`[worker-smoke] 使用隔离环境 project=${projectName} database=${state.database}`,
);
await buildRuntimeImages();
await ensureSpacetimeImage();
await dockerCompose(['up', '-d', 'spacetimedb', 'otelcol']);
await waitForSpacetime();
await publishModule();
await upRuntime();
await waitForApi();
await assertWorkersRunning();
const beforeWorkerIds = await getContainerIds('external-generation-worker');
console.log(`[worker-smoke] worker 容器: ${beforeWorkerIds.join(', ')}`);
const firstJobId = await enqueueSmokeJob({label: 'before-api-update'});
await waitForJobConsumed(firstJobId);
await apiOnlyUpdate({build: false});
const afterWorkerIds = await getContainerIds('external-generation-worker');
if (beforeWorkerIds.join('\n') !== afterWorkerIds.join('\n')) {
throw new Error(
`api-update 后 worker 容器发生变化: before=${beforeWorkerIds.join(',')} after=${afterWorkerIds.join(',')}`,
);
}
console.log('[worker-smoke] api-only 更新未重建 worker 容器。');
const secondJobId = await enqueueSmokeJob({label: 'after-api-update'});
await waitForJobConsumed(secondJobId);
await printQueueStatus();
console.log('[worker-smoke] smoke 通过worker 独立消费队列API-only 更新未停止 worker。');
}
async function buildRuntimeImages() {
const imageMode = resolveImageMode();
if (imageMode === 'local-binary') {
await buildLocalBinaryRuntimeImages();
return;
}
await dockerCompose(['build', 'api-server', 'external-generation-worker']);
}
function resolveImageMode() {
if (rawArgs.includes('--local-binary')) {
return 'local-binary';
}
const envMode = process.env.GENARRATIVE_WORKER_SMOKE_IMAGE_MODE;
if (!envMode || envMode === 'dockerfile') {
return 'dockerfile';
}
if (envMode === 'local-binary') {
return 'local-binary';
}
throw new Error(
`GENARRATIVE_WORKER_SMOKE_IMAGE_MODE 仅支持 dockerfile 或 local-binary: ${envMode}`,
);
}
async function buildLocalBinaryRuntimeImages() {
const profile =
rawArgs.includes('--release') ||
process.env.GENARRATIVE_WORKER_SMOKE_CARGO_PROFILE === 'release'
? 'release'
: 'debug';
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});
console.log(
`[worker-smoke] 使用 ${cargoImage} 复用本机 Cargo 缓存构建 ${profile} api-server 二进制。`,
);
await run('docker', [
'run',
'--rm',
'-u',
currentUserSpec(),
'-v',
`${projectRoot}:/workspace`,
'-v',
`${cargoHome}:/cargo-home`,
'-w',
'/workspace',
'-e',
'HOME=/cargo-home',
'-e',
'CARGO_HOME=/cargo-home',
'-e',
`CARGO_TARGET_DIR=/workspace/${toContainerPath(localCargoTargetDir)}`,
cargoImage,
'cargo',
'--config',
'build.rustc-wrapper=""',
'--config',
'target.x86_64-unknown-linux-gnu.linker="cc"',
'--config',
'target.x86_64-unknown-linux-gnu.rustflags=[]',
...buildArgs,
]);
const sourceBinaryPath = path.join(localCargoTargetDir, profile, 'api-server');
if (!existsSync(sourceBinaryPath)) {
throw new Error(`未找到 worker smoke api-server 二进制: ${sourceBinaryPath}`);
}
mkdirSync(localImageDir, {recursive: true});
copyFileSync(sourceBinaryPath, localImageBinaryPath);
chmodSync(localImageBinaryPath, 0o755);
const baseImage = await resolveLocalBinaryBaseImage();
writeFileSync(localImageDockerfilePath, buildLocalBinaryDockerfile(baseImage), 'utf8');
await run('docker', [
'build',
'-f',
localImageDockerfilePath,
'-t',
`${projectName}-api-server`,
'-t',
`${projectName}-external-generation-worker`,
localImageDir,
]);
}
function resolveLocalBinaryCargoImage() {
return process.env.GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE || 'rust:1.93-bookworm';
}
function resolveLocalBinaryCargoHome() {
if (process.env.GENARRATIVE_WORKER_SMOKE_CARGO_HOME) {
return path.resolve(process.env.GENARRATIVE_WORKER_SMOKE_CARGO_HOME);
}
if (!process.env.HOME) {
throw new Error('未找到 HOME无法挂载本机 Cargo 缓存。');
}
return path.join(process.env.HOME, '.cargo');
}
function currentUserSpec() {
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') {
return;
}
const imageName = localSpacetimeImageName();
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 镜像。');
}
mkdirSync(localSpacetimeImageDir, {recursive: true});
copyFileSync(spacetimePath, localSpacetimeBinaryPath);
chmodSync(localSpacetimeBinaryPath, 0o755);
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');
console.log(`[worker-smoke] 使用本机 spacetime CLI 构建隔离镜像: ${imageName}`);
await run('docker', [
'build',
'-f',
localSpacetimeDockerfilePath,
'-t',
imageName,
localSpacetimeImageDir,
]);
}
function buildLocalSpacetimeDockerfile() {
return `FROM debian:bookworm-slim
WORKDIR /var/lib/spacetimedb
RUN apt-get update && \\
apt-get install -y --no-install-recommends ca-certificates libstdc++6 zlib1g && \\
rm -rf /var/lib/apt/lists/*
COPY spacetime /usr/local/bin/spacetime
COPY spacetimedb-standalone /usr/local/bin/spacetimedb-standalone
RUN chmod 0755 /usr/local/bin/spacetime /usr/local/bin/spacetimedb-standalone
ENTRYPOINT ["spacetime"]
`;
}
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 pathMatch = versionResult.stdout.match(/^spacetime Path:\s*(.+)$/mu);
if (pathMatch?.[1]) {
return pathMatch[1].trim();
}
const whichResult = await runCapture('which', ['spacetime'], {quiet: true});
return whichResult.stdout.trim();
}
async function resolveLocalBinaryBaseImage() {
if (process.env.GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE) {
return process.env.GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE;
}
return 'debian:bookworm-slim';
}
function buildLocalBinaryDockerfile(baseImage) {
return `FROM ${baseImage}
WORKDIR /srv/genarrative
RUN apt-get update && \\
apt-get install -y --no-install-recommends ca-certificates curl libssl3 zlib1g libzstd1 && \\
rm -rf /var/lib/apt/lists/* && \\
(id -u genarrative >/dev/null 2>&1 || useradd --system --create-home --home-dir /srv/genarrative --shell /usr/sbin/nologin genarrative)
COPY api-server /usr/local/bin/api-server
RUN chmod 0755 /usr/local/bin/api-server && \\
mkdir -p /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox && \\
chown -R genarrative:genarrative /srv/genarrative /var/lib/genarrative
USER genarrative
EXPOSE 8082
ENV GENARRATIVE_ENV=container \\
GENARRATIVE_API_HOST=0.0.0.0 \\
GENARRATIVE_API_PORT=8082 \\
GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
CMD ["api-server"]
`;
}
function toContainerPath(localPath) {
return localPath.split(path.sep).join('/');
}
async function upRuntime() {
const services = ['api-server', 'external-generation-worker'];
if (rawArgs.includes('--with-nginx')) {
services.push('nginx');
}
await dockerCompose(['up', '-d', ...services]);
}
async function ensureStateAndEnv(options = {}) {
const {force = false, create = true} = options;
if (!create && !existsSync(statePath)) {
return defaultState();
}
mkdirSync(smokeDir, {recursive: true});
if (!existsSync(statePath) || force) {
const state = {
database: defaultDatabase,
spacetimePort: await findAvailablePort(
Number(process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_PORT || 19101),
),
httpPort: await findAvailablePort(
Number(process.env.GENARRATIVE_WORKER_SMOKE_HTTP_PORT || 19080),
),
otlpGrpcPort: await findAvailablePort(
Number(process.env.GENARRATIVE_WORKER_SMOKE_OTLP_GRPC_PORT || 15317),
),
otlpHttpPort: await findAvailablePort(
Number(process.env.GENARRATIVE_WORKER_SMOKE_OTLP_HTTP_PORT || 15318),
),
createdAt: new Date().toISOString(),
};
writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}
const state = readState();
if (!existsSync(envPath) || force) {
writeFileSync(envPath, buildSmokeEnv(state), 'utf8');
}
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] Nginx=http://127.0.0.1:${state.httpPort}`);
return state;
}
function buildSmokeEnv(state) {
return `# 本文件由 scripts/container-worker-smoke.mjs 生成,仅用于本机隔离 worker smoke。
# 不要在这里写真实生产密钥;目录 deploy/container/worker-smoke/ 已被 gitignore。
GENARRATIVE_ENV=container-worker-smoke
GENARRATIVE_API_HOST=0.0.0.0
GENARRATIVE_API_PORT=8082
GENARRATIVE_API_LOG=info,tower_http=info
GENARRATIVE_API_LISTEN_BACKLOG=256
GENARRATIVE_API_WORKER_THREADS=2
GENARRATIVE_PROCESS_ROLE=api
GENARRATIVE_EXTERNAL_GENERATION_MODE=queue
GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=1
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=500
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=60
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=64
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=32
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=16
GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=8
GENARRATIVE_TRACKING_OUTBOX_ENABLED=false
GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
GENARRATIVE_OTEL_ENABLED=false
OTEL_SERVICE_NAME=genarrative-worker-smoke-api
OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=worker-smoke,service.namespace=genarrative
GENARRATIVE_INTERNAL_API_SECRET=worker-smoke-internal-secret
GENARRATIVE_JWT_ISSUER=genarrative-worker-smoke
GENARRATIVE_JWT_SECRET=worker-smoke-jwt-secret
AUTH_REFRESH_COOKIE_SECURE=false
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true
GENARRATIVE_SPACETIME_SERVER_URL=http://spacetimedb:3101
GENARRATIVE_SPACETIME_DATABASE=${state.database}
GENARRATIVE_SPACETIME_TOKEN=
GENARRATIVE_SPACETIME_POOL_SIZE=2
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=15
GENARRATIVE_LLM_PROVIDER=openai-compatible
GENARRATIVE_LLM_BASE_URL=
GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=
VECTOR_ENGINE_BASE_URL=
VECTOR_ENGINE_API_KEY=
ALIYUN_OSS_BUCKET=
ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
ALIYUN_OSS_ACCESS_KEY_ID=
ALIYUN_OSS_ACCESS_KEY_SECRET=
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=
`;
}
function defaultState() {
return {
database: defaultDatabase,
spacetimePort: 19101,
httpPort: 19080,
otlpGrpcPort: 15317,
otlpHttpPort: 15318,
};
}
function readState() {
if (!existsSync(statePath)) {
return defaultState();
}
return JSON.parse(readFileSync(statePath, 'utf8'));
}
async function findAvailablePort(startPort) {
for (let port = startPort; port < startPort + 100; port += 1) {
if (await isPortAvailable(port)) {
return port;
}
}
throw new Error(`未找到可用端口: ${startPort}-${startPort + 99}`);
}
function isPortAvailable(port) {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port, '127.0.0.1');
});
}
async function publishModule() {
const state = readState();
const serverUrl = spacetimeServerUrl(state);
const publishArgs = [
'publish',
state.database,
'--server',
serverUrl,
'--module-path',
'server-rs/crates/spacetime-module',
'--delete-data=on-conflict',
'--anonymous',
'--yes=all',
'--no-config',
];
const buildOptions = process.env.GENARRATIVE_WORKER_SMOKE_STDB_BUILD_OPTIONS;
if (buildOptions) {
publishArgs.push('--build-options', buildOptions);
}
await run('spacetime', publishArgs);
}
async function enqueueSmokeJob(options = {}) {
if (!rawArgs.includes('--no-worker-check')) {
await assertWorkersRunning();
}
const state = readState();
const nowMicros = Date.now() * 1000;
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const jobId = `extgen-smoke-${suffix}`;
const label = options.label || rawArgs[0] || 'manual';
const input = {
job_id: jobId,
dedupe_key: `worker-smoke:${label}:${suffix}`,
job_kind: 'worker_smoke_unsupported',
owner_user_id: 'worker-smoke-user',
source_module: 'worker-smoke',
source_entity_id: `worker-smoke-entity-${suffix}`,
request_label: `worker-smoke ${label}`,
request_payload_json: JSON.stringify({label, suffix}),
max_attempts: 1,
available_at_micros: nowMicros,
created_at_micros: nowMicros,
};
await run('spacetime', [
'call',
'--server',
spacetimeServerUrl(state),
'--anonymous',
'--yes',
'--no-config',
state.database,
'enqueue_external_generation_job_and_return',
JSON.stringify(input),
]);
console.log(`[worker-smoke] 已入队测试 job: ${jobId}`);
return jobId;
}
async function printQueueStatus() {
console.log('[worker-smoke] external_generation_job 是 private tablestatus 显示最近 worker 日志:');
await printServiceLogs('external-generation-worker', 120);
}
async function waitForJobConsumed(jobId) {
const deadline = Date.now() + 60_000;
let lastOutput = '';
while (Date.now() < deadline) {
const result = await dockerComposeCapture(
['logs', '--no-color', 'external-generation-worker'],
{allowFailure: true, quiet: true},
);
lastOutput = `${result.stdout}\n${result.stderr}`;
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}`);
}
async function assertSavedPortsAvailableForNewProject(state) {
const existingContainers = await getProjectContainerIds();
if (existingContainers.length > 0) {
return;
}
const ports = [
['SpacetimeDB', state.spacetimePort],
['Nginx', state.httpPort],
['OTLP gRPC', state.otlpGrpcPort],
['OTLP HTTP', state.otlpHttpPort],
];
for (const [label, port] of ports) {
if (!(await isPortAvailable(port))) {
throw new Error(
`${label} 端口 ${port} 已被占用;可执行 npm run container:worker-smoke -- smoke --force 重新分配隔离端口。`,
);
}
}
}
async function getProjectContainerIds() {
const result = await dockerComposeCapture(['ps', '-q'], {
allowFailure: true,
quiet: true,
});
if (result.code !== 0) {
return [];
}
return result.stdout
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean);
}
async function assertWorkersRunning() {
const result = await dockerComposeCapture(
['ps', '--status', 'running', '-q', 'external-generation-worker'],
{allowFailure: true, quiet: true},
);
const workerIds = result.stdout
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean);
if (result.code === 0 && workerIds.length > 0) {
return;
}
await printServiceLogs('external-generation-worker', 80);
throw new Error('external-generation-worker 未处于 running 状态,已输出最近日志。');
}
async function printServiceLogs(service, tail = 80) {
await dockerComposeCapture(['logs', '--tail', String(tail), service], {
allowFailure: true,
});
}
async function waitForSpacetime() {
const state = readState();
const url = `${spacetimeServerUrl(state)}/v1/ping`;
await waitForHttp(url, 'SpacetimeDB');
}
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},
);
if (result.code === 0) {
console.log('[worker-smoke] api-server 已就绪: api-server:8082/healthz');
return;
}
await sleep(2000);
}
throw new Error('api-server 等待超时: api-server:8082/healthz');
}
async function waitForHttp(url, label) {
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
const result = await runCapture('curl', ['-fsS', '--max-time', '3', url], {
allowFailure: true,
});
if (result.code === 0) {
console.log(`[worker-smoke] ${label} 已就绪: ${url}`);
return;
}
await sleep(2000);
}
throw new Error(`${label} 等待超时: ${url}`);
}
async function apiOnlyUpdate({build}) {
const beforeWorkerIds = await getContainerIds('external-generation-worker');
const args = ['up', '-d', '--no-deps', '--force-recreate'];
if (build) {
args.push('--build');
}
args.push('api-server');
await dockerCompose(args);
await waitForApi();
const afterWorkerIds = await getContainerIds('external-generation-worker');
if (beforeWorkerIds.join('\n') !== afterWorkerIds.join('\n')) {
throw new Error('API-only 更新不应重建 external-generation-worker 容器');
}
console.log('[worker-smoke] API-only 更新完成worker 容器保持不变。');
}
async function scaleWorkers(rawCount) {
const count = Number.parseInt(rawCount, 10);
if (!Number.isInteger(count) || count < 0 || count > 16) {
throw new Error(`worker 数量必须是 0-16 的整数: ${rawCount}`);
}
await dockerCompose([
'up',
'-d',
'--scale',
`external-generation-worker=${count}`,
'external-generation-worker',
]);
}
async function getContainerIds(service) {
const result = await dockerComposeCapture(['ps', '-q', service]);
return result.stdout
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean)
.sort();
}
async function dockerCompose(args) {
await run('docker', composeArgs(args), {env: composeEnv()});
}
async function dockerComposeCapture(args, options = {}) {
return runCapture('docker', composeArgs(args), {
env: composeEnv(),
...options,
});
}
function composeArgs(args) {
return ['compose', '-p', projectName, '-f', composeFile, ...args];
}
function composeEnv() {
const state = readState();
return {
...process.env,
GENARRATIVE_CONTAINER_API_ENV_FILE: './worker-smoke/api-server.env',
GENARRATIVE_CONTAINER_SPACETIME_IMAGE:
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),
GENARRATIVE_CONTAINER_OTLP_HTTP_PORT: String(state.otlpHttpPort),
};
}
function localSpacetimeImageName() {
return `${projectName}-spacetimedb:2.5.0`;
}
function spacetimeServerUrl(state) {
return `http://127.0.0.1:${state.spacetimePort}`;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, 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}`);
}
return result;
}
function runCapture(commandName, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(commandName, args, {
cwd: projectRoot,
env: options.env ?? process.env,
shell: false,
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (chunk) => {
const text = chunk.toString();
stdout += text;
if (!options.quiet) {
process.stdout.write(text);
}
});
child.stderr?.on('data', (chunk) => {
const text = chunk.toString();
stderr += text;
if (!options.quiet) {
process.stderr.write(text);
}
});
child.on('error', reject);
child.on('exit', (code, signal) => {
if (signal) {
reject(new Error(`${commandName} 被信号终止: ${signal}`));
return;
}
resolve({code: code ?? 0, stdout, stderr});
});
});
}
function printHelp(isError) {
const output = isError ? console.error : console.log;
output(`Usage: npm run container:worker-smoke -- <command>
Commands:
init [--force] 生成隔离 env 与端口 state
build [--local-binary] [--release]
构建 api-server / worker 镜像;--local-binary 让容器内 Cargo 复用本机缓存
up-spacetime 启动隔离 SpacetimeDB 与 otelcol
publish 向隔离 SpacetimeDB 发布 spacetime-module
up [--with-nginx] 启动 api-server / worker需要 Nginx 时显式加 --with-nginx
enqueue [label] [--no-worker-check]
写入一个 unsupported 测试 job验证 worker claim/fail
status 查看最近 worker 日志external_generation_job 是 private table
api-update [--build] 仅重建/重启 api-server不触碰 worker
scale <n> 调整 external-generation-worker 实例数
ps 查看隔离 compose 状态
logs [service] 查看隔离 compose 日志
down [-v] 停止隔离 compose-v 会清理数据卷
smoke [--force] [--local-binary] [--release]
一键执行 build -> publish -> up -> enqueue -> api-update -> enqueue
`);
}

View File

@@ -5,10 +5,11 @@ set -euo pipefail
usage() {
cat <<'EOF'
用法:
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101]
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--worker-service-pattern 'genarrative-external-generation-worker@*.service'] [--no-worker-services] [--worker-controller-service genarrative-external-generation-controller.service] [--no-worker-controller] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101]
说明:
进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。
默认同时重启外部生成 worker controller 和已加载的 worker 实例;未启用 worker 单元时会自动跳过。
若传入 --database会在重启前把 GENARRATIVE_SPACETIME_DATABASE 写入 api-server 环境文件,避免服务继续读取旧库。
失败时保留维护模式。
EOF
@@ -223,12 +224,144 @@ ensure_runtime_env_and_dirs() {
fi
}
list_worker_services() {
local pattern="$1"
if [[ -z "${pattern}" ]]; then
return 0
fi
systemctl list-units --all --plain --no-legend "${pattern}" 2>/dev/null | awk '{print $1}' | sort -u
}
ensure_default_worker_service() {
local pattern="$1"
local default_service="genarrative-external-generation-worker@1.service"
local template_service="genarrative-external-generation-worker@.service"
local services=()
if [[ -z "${pattern}" ]]; then
return 0
fi
if [[ "${pattern}" != "genarrative-external-generation-worker@*.service" ]]; then
return 0
fi
if ! systemctl cat "${template_service}" >/dev/null 2>&1; then
echo "[production-api-deploy] 缺少外部生成 worker systemd 模板: ${template_service}" >&2
return 1
fi
mapfile -t services < <(list_worker_services "${pattern}")
if [[ "${#services[@]}" -gt 0 ]]; then
return 0
fi
echo "[production-api-deploy] 未发现外部生成 worker 实例,启用并启动默认实例: ${default_service}"
systemctl enable --now "${default_service}"
}
restart_worker_services() {
local pattern="$1"
local services=()
if [[ -z "${pattern}" ]]; then
echo "[production-api-deploy] 跳过外部生成 worker 重启。"
return 0
fi
ensure_default_worker_service "${pattern}"
mapfile -t services < <(list_worker_services "${pattern}")
if [[ "${#services[@]}" -eq 0 ]]; then
echo "[production-api-deploy] 未发现已加载的外部生成 worker 单元: ${pattern}" >&2
return 1
fi
echo "[production-api-deploy] 重启外部生成 worker: ${services[*]}"
systemctl restart "${services[@]}"
}
wait_for_worker_services() {
local pattern="$1"
local services=()
local all_active
if [[ -z "${pattern}" ]]; then
return 0
fi
mapfile -t services < <(list_worker_services "${pattern}")
if [[ "${#services[@]}" -eq 0 ]]; then
echo "[production-api-deploy] 外部生成 worker 单元不存在,发布失败: ${pattern}" >&2
return 1
fi
echo "[production-api-deploy] 等待外部生成 worker active: ${services[*]}"
for _ in {1..30}; do
all_active=1
for service in "${services[@]}"; do
if ! systemctl is-active --quiet "${service}"; then
all_active=0
break
fi
done
if [[ "${all_active}" -eq 1 ]]; then
return 0
fi
sleep 2
done
systemctl --no-pager --full status "${services[@]}" || true
echo "[production-api-deploy] 外部生成 worker 未在超时时间内进入 active发布失败。" >&2
return 1
}
ensure_worker_controller_service() {
local service="$1"
if [[ -z "${service}" ]]; then
return 0
fi
if ! systemctl cat "${service}" >/dev/null 2>&1; then
echo "[production-api-deploy] 缺少外部生成 worker controller systemd 单元: ${service}" >&2
return 1
fi
echo "[production-api-deploy] 启用并重启外部生成 worker controller: ${service}"
systemctl enable "${service}"
systemctl restart "${service}"
}
wait_for_worker_controller_service() {
local service="$1"
if [[ -z "${service}" ]]; then
return 0
fi
echo "[production-api-deploy] 等待外部生成 worker controller active: ${service}"
for _ in {1..30}; do
if systemctl is-active --quiet "${service}"; then
return 0
fi
sleep 2
done
systemctl --no-pager --full status "${service}" || true
echo "[production-api-deploy] 外部生成 worker controller 未在超时时间内进入 active发布失败。" >&2
return 1
}
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
SOURCE_DIR=""
VERSION=""
RELEASE_ROOT="/opt/genarrative/releases"
CURRENT_LINK="/opt/genarrative/current"
SERVICE_NAME="genarrative-api.service"
WORKER_SERVICE_PATTERN="genarrative-external-generation-worker@*.service"
WORKER_CONTROLLER_SERVICE="genarrative-external-generation-controller.service"
HEALTH_URL="http://127.0.0.1:8082/readyz"
API_ENV_FILE="/etc/genarrative/api-server.env"
DATABASE=""
@@ -261,6 +394,22 @@ while [[ $# -gt 0 ]]; do
SERVICE_NAME="${2:?缺少 --service 的值}"
shift 2
;;
--worker-service-pattern)
WORKER_SERVICE_PATTERN="${2:?缺少 --worker-service-pattern 的值}"
shift 2
;;
--no-worker-services)
WORKER_SERVICE_PATTERN=""
shift
;;
--worker-controller-service)
WORKER_CONTROLLER_SERVICE="${2:?缺少 --worker-controller-service 的值}"
shift 2
;;
--no-worker-controller)
WORKER_CONTROLLER_SERVICE=""
shift
;;
--health-url)
HEALTH_URL="${2:?缺少 --health-url 的值}"
shift 2
@@ -383,6 +532,10 @@ ln -sfn "${RELEASE_DIR}" "${CURRENT_LINK}"
echo "[production-api-deploy] 重启服务: ${SERVICE_NAME}"
systemctl restart "${SERVICE_NAME}"
restart_worker_services "${WORKER_SERVICE_PATTERN}"
wait_for_worker_services "${WORKER_SERVICE_PATTERN}"
ensure_worker_controller_service "${WORKER_CONTROLLER_SERVICE}"
wait_for_worker_controller_service "${WORKER_CONTROLLER_SERVICE}"
echo "[production-api-deploy] 等待 readiness: ${HEALTH_URL}"
for _ in {1..30}; do

View File

@@ -37,6 +37,7 @@ const manifestPath = resolve(serverRsDir, 'Cargo.toml');
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs');
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
const LOCAL_DEV_RUSTC_WRAPPER_BYPASS = process.platform === 'win32' ? 'rustc' : '/usr/bin/env';
const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web'];
const SERVICE_ALIASES = new Map([
@@ -399,6 +400,39 @@ function requireCommand(command) {
}
}
function isSccacheRustcWrapper(value) {
const wrapper = String(value ?? '').trim();
if (!wrapper) {
return false;
}
const command = wrapper.split(/[\\/]/).pop()?.toLowerCase();
return command === 'sccache' || command === 'sccache.exe';
}
function buildLocalRustProcessEnv(env, options = {}) {
const mergedEnv = {...env};
const wrappers = [
String(mergedEnv.RUSTC_WRAPPER ?? '').trim(),
String(mergedEnv.CARGO_BUILD_RUSTC_WRAPPER ?? '').trim(),
].filter(Boolean);
const customWrapper = wrappers.find((wrapper) => !isSccacheRustcWrapper(wrapper));
if (customWrapper) {
mergedEnv.RUSTC_WRAPPER = customWrapper;
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = customWrapper;
return mergedEnv;
}
mergedEnv.RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS;
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS;
if (options.log !== false) {
console.warn(
'[dev:rust] 本地 dev 构建绕过项目 sccache wrapper避免缓存进程异常阻断启动。',
);
}
return mergedEnv;
}
function readWorkspaceSpacetimeVersion() {
const manifestText = readFileSync(manifestPath, 'utf8');
const match = /^spacetimedb\s*=\s*(?:"([^"]+)"|\{[^}]*version\s*=\s*"([^"]+)")/mu.exec(
@@ -451,7 +485,7 @@ function assertSpacetimeToolVersionMatchesWorkspace({
[
`本机 spacetime CLI/standalone 版本 ${toolVersion} 与 server-rs 锁定的 SpacetimeDB ${workspaceVersion} 不一致。`,
'版本错位会导致 procedure 返回值 BSATN 反序列化失败,前端表现为 SpacetimeDB procedure 调用超时。',
`执行 spacetime version install ${workspaceVersion} && spacetime version use ${workspaceVersion} 后重新运行本命令。`,
`先直接升级并切换到锁定版本: spacetime version install ${workspaceVersion} && spacetime version use ${workspaceVersion},然后重新运行本命令。`,
].join(''),
);
}
@@ -479,9 +513,11 @@ function assertReusableSpacetimeProcessVersionMatchesWorkspace({
[
`正在运行的本地 SpacetimeDB standalone 版本 ${recordedVersion} 与 server-rs 锁定的 SpacetimeDB ${workspaceVersion} 不一致。`,
'版本错位会导致 procedure 返回值 BSATN 反序列化失败,前端表现为 SpacetimeDB procedure 调用超时。',
'请停止当前 SpacetimeDB 进程,执行 spacetime version use ',
'请停止当前 SpacetimeDB 进程,先直接升级并切换到锁定版本: spacetime version install ',
workspaceVersion,
' 后重新运行 npm run dev:spacetime。',
' && spacetime version use ',
workspaceVersion,
',然后重新运行 npm run dev:spacetime。',
].join(''),
);
}
@@ -776,7 +812,7 @@ class DevRunner {
this.writeDevStackState();
}
async prepareLinuxPortRange(command) {
async prepareLinuxPortRange() {
if (process.platform !== 'linux') {
return;
}
@@ -1228,7 +1264,7 @@ class DevRunner {
}
async publishSpacetimeModule() {
const env = {...this.baseEnv};
const env = buildLocalRustProcessEnv(this.baseEnv);
this.prepareMigrationBootstrapSecret(env);
const args = buildSpacetimePublishArgs({
@@ -1291,7 +1327,7 @@ class DevRunner {
await this.ensureApiServerSpacetimeToken();
const mergedEnv = buildApiServerProcessEnv({
baseEnv: this.baseEnv,
baseEnv: buildLocalRustProcessEnv(this.baseEnv),
options: this.options,
state: this.state,
});
@@ -2124,19 +2160,20 @@ function buildApiServerProcessEnv({baseEnv, options, state}) {
}
export {
DevRunner,
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
buildApiServerProcessEnv,
buildDevStackSnapshot,
buildLocalRustProcessEnv,
buildSpacetimePublishArgs,
createDevServerSpawnOptions,
createWatchConfigs,
isSpacetimePublishPermissionError,
DevRunner,
isDirectModuleExecution,
isSpacetimePublishPermissionError,
normalizeCargoVersionRequirement,
parseSpacetimeToolVersion,
parseArgs,
parseSpacetimeToolVersion,
resolveDevStackStatePath,
shouldAcceptWatchEvent,
};

View File

@@ -5,19 +5,20 @@ import {join} from 'node:path';
import {afterEach, describe, expect, test, vi} from 'vitest';
import {
DevRunner,
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
buildApiServerProcessEnv,
buildDevStackSnapshot,
buildLocalRustProcessEnv,
buildSpacetimePublishArgs,
createDevServerSpawnOptions,
createWatchConfigs,
DevRunner,
isDirectModuleExecution,
isSpacetimePublishPermissionError,
normalizeCargoVersionRequirement,
parseSpacetimeToolVersion,
parseArgs,
parseSpacetimeToolVersion,
resolveDevStackStatePath,
shouldAcceptWatchEvent,
} from './dev.mjs';
@@ -185,6 +186,35 @@ describe('dev scheduler api-server env', () => {
});
});
describe('dev scheduler Rust build env', () => {
test('local dev Rust env bypasses project sccache wrapper', () => {
const env = buildLocalRustProcessEnv(
{
RUSTC_WRAPPER: '/usr/bin/sccache',
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
},
{log: false},
);
expect(env.RUSTC_WRAPPER).not.toBe('/usr/bin/sccache');
expect(env.RUSTC_WRAPPER).not.toBe('sccache');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER);
});
test('local dev Rust env keeps healthy custom wrapper untouched', () => {
const env = buildLocalRustProcessEnv(
{
RUSTC_WRAPPER: 'custom-wrapper',
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
},
{log: false},
);
expect(env.RUSTC_WRAPPER).toBe('custom-wrapper');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('custom-wrapper');
});
});
describe('dev scheduler stack state file', () => {
test('状态文件路径固定在根目录 .app/dev-stack.json', () => {
expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe(
@@ -404,24 +434,24 @@ describe('dev scheduler watch routing', () => {
describe('dev scheduler spacetime refresh', () => {
test('解析 Cargo 精确版本要求时用于 CLI 校验的版本号不带等号', () => {
expect(normalizeCargoVersionRequirement('=2.4.1')).toBe('2.4.1');
expect(normalizeCargoVersionRequirement('2.4.1')).toBe('2.4.1');
expect(normalizeCargoVersionRequirement('=2.5.0')).toBe('2.5.0');
expect(normalizeCargoVersionRequirement('2.5.0')).toBe('2.5.0');
});
test('解析 spacetime --version 输出里的 tool version', () => {
const version = parseSpacetimeToolVersion(`
A new version of SpacetimeDB is available: v2.4.1 (current: v2.4.0)
spacetimedb tool version 2.4.1; spacetimedb-lib version 2.4.1;
A new version of SpacetimeDB is available: v2.5.0 (current: v2.4.1)
spacetimedb tool version 2.5.0; spacetimedb-lib version 2.5.0;
`);
expect(version).toBe('2.4.1');
expect(version).toBe('2.5.0');
});
test('本机 spacetime 版本和 workspace 锁定版本不一致时直接报清楚', () => {
expect(() =>
assertSpacetimeToolVersionMatchesWorkspace({
toolVersion: '2.1.0',
workspaceVersion: '2.4.1',
workspaceVersion: '2.5.0',
}),
).toThrow('procedure 返回值 BSATN 反序列化失败');
});

View File

@@ -4,6 +4,8 @@ set -euo pipefail
PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}"
SPACETIME_BIN_SOURCE="${SPACETIME_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/spacetime/spacetime}"
OTELCOL_BIN_SOURCE="${OTELCOL_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/otelcol-contrib}"
WORKER_ENV_FILE="${WORKER_ENV_FILE:-/etc/genarrative/external-generation-worker.env}"
CONTROLLER_ENV_FILE="${CONTROLLER_ENV_FILE:-/etc/genarrative/external-generation-controller.env}"
GENARRATIVE_OPENSSL_VERSION="${GENARRATIVE_OPENSSL_VERSION:-3.2.0}"
GENARRATIVE_OPENSSL_PREFIX="${GENARRATIVE_OPENSSL_PREFIX:-/opt/genarrative/openssl-3.2.0}"
GENARRATIVE_OPENSSL_SOURCE_URL="${GENARRATIVE_OPENSSL_SOURCE_URL:-https://github.com/openssl/openssl/releases/download/openssl-${GENARRATIVE_OPENSSL_VERSION}/openssl-${GENARRATIVE_OPENSSL_VERSION}.tar.gz}"
@@ -242,6 +244,47 @@ sync_otelcol_install() {
fi
}
ensure_otelcol_runtime() {
if [[ "${ENABLE_OTELCOL:-true}" != "true" ]]; then
return
fi
if [[ "${DRY_RUN}" == "true" ]]; then
echo "+ ensure system user/group otelcol"
echo "+ install -d -m 0755 -o otelcol -g otelcol /var/lib/otelcol"
echo "+ install -d -m 0755 -o root -g root /etc/otelcol"
echo "+ install -d -m 0755 -o genarrative -g genarrative /var/log/genarrative"
echo "+ install -m 0644 deploy/otelcol/genarrative-debug.yaml /etc/otelcol/genarrative-debug.yaml"
return
fi
if ! getent group otelcol >/dev/null 2>&1; then
groupadd --system otelcol
fi
if ! id otelcol >/dev/null 2>&1; then
useradd --system --gid otelcol --home-dir /var/lib/otelcol --shell /usr/sbin/nologin otelcol
fi
install -d -m 0755 -o otelcol -g otelcol /var/lib/otelcol
install -d -m 0755 -o root -g root /etc/otelcol
install -d -m 0755 -o genarrative -g genarrative /var/log/genarrative
install -m 0644 deploy/otelcol/genarrative-debug.yaml /etc/otelcol/genarrative-debug.yaml
chown root:root /etc/otelcol/genarrative-debug.yaml
}
stamp_database_backup_timer_now() {
if [[ "${DRY_RUN}" == "true" ]]; then
echo "+ install -d -m 0755 /var/lib/systemd/timers"
echo "+ touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer"
return
fi
install -d -m 0755 /var/lib/systemd/timers
# 避免 provision 在当天 03:20 之后启动 timer 时因 Persistent=true 立刻补跑冷备份、
# 进而在初始化/发布窗口中意外停止 spacetimedb.service。
touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer
}
sync_spacetime_install() {
local root_dir="$1"
local target_bin_dir="${root_dir}/bin/current"
@@ -458,6 +501,7 @@ ensure_spacetime_owner_client_token() {
echo "[server-provision] 已生成 SpacetimeDB client identity 并写入 GENARRATIVE_SPACETIME_TOKEN: ${identity_preview}..."
fi
# 中文注释:这里是 provision 内部为 spacetimedb 运行用户隔离 CLI 登录态的受控用法,不作为人工 spacetime 命令示例。
if ! login_output="$(runuser -u spacetimedb -- "${cli_path}" --root-dir "${SPACETIME_ROOT}" login --token "${token}" 2>&1)"; then
echo "[server-provision] 使用 GENARRATIVE_SPACETIME_TOKEN 登录 SpacetimeDB CLI 失败。" >&2
printf "%s\\n" "${login_output}" | sed -E "s/[A-Za-z0-9_.=-]{24,}/[REDACTED]/g" >&2
@@ -536,6 +580,14 @@ render_api_env_example() {
deploy/env/api-server.env.example
}
render_external_generation_worker_env_example() {
cat deploy/env/external-generation-worker.env.example
}
render_external_generation_controller_env_example() {
cat deploy/env/external-generation-controller.env.example
}
render_otelcol_service() {
cat deploy/systemd/otelcol-contrib.service
}
@@ -722,6 +774,30 @@ render_api_service() {
deploy/systemd/genarrative-api.service
}
render_external_generation_worker_service() {
local current_escaped api_env_escaped worker_env_escaped
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
api_env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")"
worker_env_escaped="$(escape_sed_replacement "${WORKER_ENV_FILE}")"
sed \
-e "s|/opt/genarrative/current|${current_escaped}|g" \
-e "s|/etc/genarrative/api-server.env|${api_env_escaped}|g" \
-e "s|/etc/genarrative/external-generation-worker.env|${worker_env_escaped}|g" \
deploy/systemd/genarrative-external-generation-worker@.service
}
render_external_generation_controller_service() {
local current_escaped api_env_escaped controller_env_escaped
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
api_env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")"
controller_env_escaped="$(escape_sed_replacement "${CONTROLLER_ENV_FILE}")"
sed \
-e "s|/opt/genarrative/current|${current_escaped}|g" \
-e "s|/etc/genarrative/api-server.env|${api_env_escaped}|g" \
-e "s|/etc/genarrative/external-generation-controller.env|${controller_env_escaped}|g" \
deploy/systemd/genarrative-external-generation-controller.service
}
render_database_backup_service() {
local current_escaped env_escaped
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
@@ -742,6 +818,8 @@ render_health_patrol_service() {
require_path deploy/systemd/spacetimedb.service
require_path deploy/systemd/genarrative-api.service
require_path deploy/systemd/genarrative-external-generation-worker@.service
require_path deploy/systemd/genarrative-external-generation-controller.service
require_path deploy/systemd/genarrative-database-backup.service
require_path deploy/systemd/genarrative-database-backup.timer
require_path deploy/systemd/genarrative-health-patrol.service
@@ -752,6 +830,8 @@ require_path deploy/nginx/genarrative.conf
require_path deploy/nginx/genarrative-dev-http.conf
require_path deploy/nginx/snippets/genarrative-maintenance.conf
require_path deploy/env/api-server.env.example
require_path deploy/env/external-generation-worker.env.example
require_path deploy/env/external-generation-controller.env.example
require_path scripts/deploy/maintenance-on.sh
require_path scripts/deploy/maintenance-off.sh
require_path scripts/deploy/maintenance-status.sh
@@ -795,19 +875,25 @@ sync_spacetime_install "${SPACETIME_ROOT}"
spacetimedb_service="$(mktemp)"
api_service="$(mktemp)"
external_generation_worker_service="$(mktemp)"
external_generation_controller_service="$(mktemp)"
database_backup_service="$(mktemp)"
health_patrol_service="$(mktemp)"
render_spacetimedb_service >"${spacetimedb_service}"
render_api_service >"${api_service}"
render_external_generation_worker_service >"${external_generation_worker_service}"
render_external_generation_controller_service >"${external_generation_controller_service}"
render_database_backup_service >"${database_backup_service}"
render_health_patrol_service >"${health_patrol_service}"
install_file "${spacetimedb_service}" /etc/systemd/system/spacetimedb.service 0644
install_file "${api_service}" /etc/systemd/system/genarrative-api.service 0644
install_file "${external_generation_worker_service}" /etc/systemd/system/genarrative-external-generation-worker@.service 0644
install_file "${external_generation_controller_service}" /etc/systemd/system/genarrative-external-generation-controller.service 0644
install_file "${database_backup_service}" /etc/systemd/system/genarrative-database-backup.service 0644
install_file deploy/systemd/genarrative-database-backup.timer /etc/systemd/system/genarrative-database-backup.timer 0644
install_file "${health_patrol_service}" /etc/systemd/system/genarrative-health-patrol.service 0644
install_file deploy/systemd/genarrative-health-patrol.timer /etc/systemd/system/genarrative-health-patrol.timer 0644
rm -f "${spacetimedb_service}" "${api_service}" "${database_backup_service}" "${health_patrol_service}"
rm -f "${spacetimedb_service}" "${api_service}" "${external_generation_worker_service}" "${external_generation_controller_service}" "${database_backup_service}" "${health_patrol_service}"
if [[ ! -f "${API_ENV_FILE}" ]]; then
echo "+ create ${API_ENV_FILE} from example"
@@ -821,8 +907,31 @@ else
fi
ensure_api_runtime_env_defaults
if [[ ! -f "${WORKER_ENV_FILE}" ]]; then
echo "+ create ${WORKER_ENV_FILE} from example"
if [[ "${DRY_RUN}" != "true" ]]; then
render_external_generation_worker_env_example >"${WORKER_ENV_FILE}"
chmod 0600 "${WORKER_ENV_FILE}"
chown root:root "${WORKER_ENV_FILE}"
fi
else
echo "[server-provision] 已存在 worker 环境文件,保留不覆盖: ${WORKER_ENV_FILE}"
fi
if [[ ! -f "${CONTROLLER_ENV_FILE}" ]]; then
echo "+ create ${CONTROLLER_ENV_FILE} from example"
if [[ "${DRY_RUN}" != "true" ]]; then
render_external_generation_controller_env_example >"${CONTROLLER_ENV_FILE}"
chmod 0600 "${CONTROLLER_ENV_FILE}"
chown root:root "${CONTROLLER_ENV_FILE}"
fi
else
echo "[server-provision] 已存在 controller 环境文件,保留不覆盖: ${CONTROLLER_ENV_FILE}"
fi
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
sync_otelcol_install
ensure_otelcol_runtime
otelcol_service="$(mktemp)"
render_otelcol_service >"${otelcol_service}"
install_file "${otelcol_service}" /etc/systemd/system/otelcol-contrib.service 0644
@@ -842,7 +951,9 @@ if [[ "${ENABLE_SERVICES}" == "true" ]]; then
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
run_cmd systemctl enable otelcol-contrib.service
fi
run_cmd systemctl enable spacetimedb.service genarrative-api.service genarrative-database-backup.timer genarrative-health-patrol.timer
stamp_database_backup_timer_now
run_cmd systemctl enable spacetimedb.service genarrative-api.service genarrative-database-backup.timer genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service genarrative-health-patrol.timer
run_cmd systemctl start genarrative-database-backup.timer
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
run_cmd systemctl restart otelcol-contrib.service
fi
@@ -851,8 +962,12 @@ if [[ "${ENABLE_SERVICES}" == "true" ]]; then
ensure_spacetime_owner_client_token
if [[ -x "${CURRENT_LINK}/api-server" ]]; then
run_cmd systemctl restart genarrative-api.service
run_cmd systemctl enable --now genarrative-external-generation-worker@1.service
run_cmd systemctl restart genarrative-external-generation-worker@1.service
run_cmd systemctl enable --now genarrative-external-generation-controller.service
run_cmd systemctl restart genarrative-external-generation-controller.service
else
echo "[server-provision] 尚未发现 ${CURRENT_LINK}/api-server跳过 api-server 首次启动。后续 API deploy 会重启服务"
echo "[server-provision] 尚未发现 ${CURRENT_LINK}/api-server跳过 api-server、外部生成 worker 和 controller 首次启动。后续 API deploy 会启用并启动默认 worker 与 controller"
fi
fi

View File

@@ -20,9 +20,11 @@ const DEFAULT_PUBLIC_PATHS = [
const DEFAULT_SERVICES = [
'genarrative-api.service',
'genarrative-external-generation-controller.service',
'spacetimedb.service',
'nginx.service',
];
const WORKER_SERVICE_PATTERN = 'genarrative-external-generation-worker@*.service';
function usage() {
console.log(`Usage:
@@ -216,6 +218,61 @@ async function checkService(serviceName, timeoutMs) {
);
}
async function checkActiveWorkerInstances(config) {
const result = await runCommand(
'systemctl',
[
'list-units',
WORKER_SERVICE_PATTERN,
'--type=service',
'--state=active',
'--no-legend',
'--plain',
'--no-pager',
],
config.timeoutMs,
);
if (result.code !== 0) {
return checkResult(
'service:external-generation-workers',
'CRITICAL',
'无法枚举外部生成 worker 实例',
{
command: result.command,
stderr: result.stderr.trim() || result.error,
},
);
}
const services = result.stdout
.split('\n')
.map((line) => line.trim().split(/\s+/u)[0])
.filter((service) =>
/^genarrative-external-generation-worker@.+\.service$/u.test(service),
);
if (services.length === 0) {
return checkResult(
'service:external-generation-workers',
'CRITICAL',
'没有 active 的外部生成 worker 实例',
{
command: result.command,
},
);
}
return checkResult(
'service:external-generation-workers',
'OK',
`${services.length} 个 worker active`,
{
command: result.command,
services,
},
);
}
function requestUrl(url, timeoutMs) {
return new Promise((resolve) => {
const startedAt = Date.now();
@@ -310,6 +367,10 @@ async function checkRecentJournal(config) {
'-u',
'genarrative-api.service',
'-u',
'genarrative-external-generation-controller.service',
'-u',
WORKER_SERVICE_PATTERN,
'-u',
'spacetimedb.service',
'-u',
'nginx.service',
@@ -426,6 +487,7 @@ async function main() {
for (const serviceName of DEFAULT_SERVICES) {
checks.push(await checkService(serviceName, config.timeoutMs));
}
checks.push(await checkActiveWorkerInstances(config));
checks.push(await checkHttp('api:/healthz', joinUrl(config.apiBaseUrl, '/healthz'), config));
checks.push(await checkHttp('api:/readyz', joinUrl(config.apiBaseUrl, '/readyz'), config));

View File

@@ -9,7 +9,7 @@ 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.4.1}"
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.5.0}"
SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}"
SPACETIME_ROOT="${SPACETIME_ROOT:-/stdb}"
SPACETIME_EXPECTED_VERSION="${SPACETIME_EXPECTED_VERSION:-}"

83
scripts/rag/README.md Normal file
View File

@@ -0,0 +1,83 @@
# 本地 RAG
本目录提供项目文档的本地 RAG 索引脚本,主要供 Agent 在执行任务前后检索项目上下文使用。它不是新的人工阅读入口;开发者仍按 `AGENTS.md``docs/README.md``docs/project-memory/` 阅读项目文档。
项目默认不安装 RAG 运行时依赖,也不把 LanceDB、Transformers.js 或本地模型写入根 `package.json`
## 运行时依赖
RAG 运行时依赖安装在 gitignored 的 `.rag/runtime/`,模型缓存和向量库也都在 `.rag/` 下。
Agent 需要启用 RAG 检索时,应先询问用户是否安装本地依赖。用户确认后执行:
```bash
mkdir -p .rag/runtime
npm init -y --prefix .rag/runtime
npm install --prefix .rag/runtime @lancedb/lancedb@0.30.0 @huggingface/transformers@4.2.0
```
不要把这些依赖加入根 `package.json`
## 索引
首次运行会下载本地 embedding 模型到 `.rag/models/`。默认模型为 `Xenova/multilingual-e5-small`,适合中英文混合文档。
```bash
npm run rag:index
```
小样本 smoke
```bash
npm run rag:index -- --limit-files 3
```
只查看分片,不加载模型:
```bash
npm run rag:index -- --limit-files 3 --dry-run
```
## 搜索
默认输出 Agent 上下文包,包含来源、分数、候选上下文和使用规则:
```bash
npm run rag:search -- --query "SpacetimeDB schema 变更默认值" --limit 8
```
可限制上下文包大小:
```bash
npm run rag:search -- --query "SpacetimeDB schema 变更默认值" --limit 8 --max-chars 8000
```
可输出结构化格式,便于 Agent 或其它工具解析:
```bash
npm run rag:search -- --query "SpacetimeDB schema 变更默认值" --format json
npm run rag:search -- --query "SpacetimeDB schema 变更默认值" --format jsonl
```
如只想看短摘要,可使用旧式文本结果:
```bash
npm run rag:search -- --query "SpacetimeDB schema 变更默认值" --format text
```
Agent 使用规则:
- 把 RAG 输出视为候选上下文,不直接当作最终事实。
- 需要精确改代码或文档时,仍要打开对应源文件核对。
- 来源冲突时,以当前代码和最新 `docs/` 为准。
## 索引范围
索引范围由 `scripts/rag/rag-config.json` 配置,默认包含:
- `AGENTS.md`
- `CONTEXT.md`,如果存在
- `docs/project-memory/`
- `docs/`
`.hermes/` 是 Hermes 工具目录,不作为项目知识库索引源。

View File

@@ -0,0 +1,68 @@
import { mkdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
buildChunkId,
chunkText,
createEmbedder,
extractTitle,
hasFlag,
listSourceFiles,
loadRagRuntime,
parseLimitFiles,
readConfig,
repoRoot,
} from './rag-utils.mjs';
const config = readConfig();
const limitFiles = parseLimitFiles(process.argv);
const dryRun = hasFlag(process.argv, '--dry-run');
const files = listSourceFiles(config, limitFiles);
const rows = [];
for (const file of files) {
const text = readFileSync(file.path, 'utf8');
const title = extractTitle(text, file.rel);
for (const chunk of chunkText(text, config.chunk ?? {})) {
rows.push({
id: buildChunkId(file.rel, chunk.index),
path: file.rel,
title,
chunk_index: chunk.index,
source_weight: file.weight,
text: chunk.text,
});
}
}
console.log(`[rag:index] source files=${files.length}, chunks=${rows.length}`);
if (dryRun) {
for (const row of rows.slice(0, 10)) {
console.log(`- ${row.id} ${row.title}`);
}
process.exit(0);
}
if (rows.length === 0) {
throw new Error('No RAG chunks found.');
}
const { lancedb, transformers } = await loadRagRuntime(config);
const embed = await createEmbedder(transformers, config.model);
for (let index = 0; index < rows.length; index += 1) {
rows[index].vector = await embed(rows[index].text, 'passage');
if ((index + 1) % 25 === 0 || index + 1 === rows.length) {
console.log(`[rag:index] embedded ${index + 1}/${rows.length}`);
}
}
mkdirSync(join(repoRoot, config.databaseDir), { recursive: true });
const db = await lancedb.connect(join(repoRoot, config.databaseDir));
await db.createTable(config.tableName, rows, { mode: 'overwrite' });
console.log(
`[rag:index] wrote table=${config.tableName}, db=${config.databaseDir}, model=${config.model}`,
);

View File

@@ -0,0 +1,46 @@
{
"runtimeDir": ".rag/runtime",
"databaseDir": ".rag/lancedb",
"modelCacheDir": ".rag/models",
"tableName": "project_docs",
"model": "Xenova/multilingual-e5-small",
"chunk": {
"maxChars": 1600,
"overlapChars": 220
},
"sources": [
{
"path": "AGENTS.md",
"weight": 1.4
},
{
"path": "CONTEXT.md",
"weight": 1.3,
"optional": true
},
{
"path": "docs/project-memory",
"weight": 1.35
},
{
"path": "docs",
"weight": 1.0
}
],
"exclude": [
".git/",
".rag/",
".hermes/",
".codegraph/",
".app/",
"node_modules/",
"dist/",
"build/",
"coverage/",
"logs/",
"output/",
"server-rs/target/",
"server-rs/target-",
"tmp/"
]
}

221
scripts/rag/rag-utils.mjs Normal file
View File

@@ -0,0 +1,221 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, extname, join, relative, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
export const configPath = join(repoRoot, 'scripts/rag/rag-config.json');
export function readConfig() {
return JSON.parse(readFileSync(configPath, 'utf8'));
}
export function normalizePath(filePath) {
return filePath.replace(/\\/gu, '/');
}
export function repoRelative(filePath) {
return normalizePath(relative(repoRoot, filePath));
}
export function resolveRepoPath(filePath) {
return resolve(repoRoot, filePath);
}
export function getRuntimeNodeModules(config) {
return join(repoRoot, config.runtimeDir, 'node_modules');
}
export function assertLocalRuntime(config) {
const runtimeModules = getRuntimeNodeModules(config);
const hasLance = existsSync(join(runtimeModules, '@lancedb/lancedb'));
const hasTransformers = existsSync(join(runtimeModules, '@huggingface/transformers'));
if (hasLance && hasTransformers) {
return runtimeModules;
}
throw new Error(
[
'本地 RAG 运行时依赖尚未安装。',
'按项目约定RAG 依赖不进入根 package.json也不默认安装。',
'需要启用 RAG 时Agent 必须先询问用户,然后在本地 gitignored 目录安装:',
'',
` mkdir -p ${config.runtimeDir}`,
` npm init -y --prefix ${config.runtimeDir}`,
` npm install --prefix ${config.runtimeDir} @lancedb/lancedb@0.30.0 @huggingface/transformers@4.2.0`,
'',
`当前检查目录:${runtimeModules}`,
].join('\n'),
);
}
export async function loadRagRuntime(config) {
const runtimeModules = assertLocalRuntime(config);
const lancedb = await import(
pathToFileURL(join(runtimeModules, '@lancedb/lancedb/dist/index.js')).href
);
const transformers = await import(
pathToFileURL(
join(runtimeModules, '@huggingface/transformers/dist/transformers.node.mjs'),
).href
);
transformers.env.cacheDir = join(repoRoot, config.modelCacheDir);
transformers.env.useFSCache = true;
transformers.env.allowRemoteModels = true;
return { lancedb, transformers };
}
export function listSourceFiles(config, limitFiles = Number.POSITIVE_INFINITY) {
const excluded = config.exclude ?? [];
const files = [];
const seen = new Set();
for (const source of config.sources ?? []) {
const sourcePath = resolveRepoPath(source.path);
if (!existsSync(sourcePath)) {
if (!source.optional) {
throw new Error(`RAG source not found: ${source.path}`);
}
continue;
}
for (const filePath of walkTextFiles(sourcePath, excluded)) {
const rel = repoRelative(filePath);
if (seen.has(rel)) {
continue;
}
seen.add(rel);
files.push({ path: filePath, rel, weight: source.weight ?? 1 });
if (files.length >= limitFiles) {
return files;
}
}
}
return files;
}
function walkTextFiles(targetPath, excluded) {
const stat = statSync(targetPath);
if (stat.isFile()) {
return shouldReadFile(targetPath, excluded) ? [targetPath] : [];
}
const files = [];
const walk = (dir) => {
for (const name of readdirSync(dir)) {
const child = join(dir, name);
const rel = `${repoRelative(child)}${statSync(child).isDirectory() ? '/' : ''}`;
if (excluded.some((prefix) => rel.startsWith(prefix))) {
continue;
}
const childStat = statSync(child);
if (childStat.isDirectory()) {
walk(child);
} else if (shouldReadFile(child, excluded)) {
files.push(child);
}
}
};
walk(targetPath);
return files.sort((a, b) => repoRelative(a).localeCompare(repoRelative(b)));
}
function shouldReadFile(filePath, excluded) {
const rel = repoRelative(filePath);
if (excluded.some((prefix) => rel.startsWith(prefix))) {
return false;
}
if (rel === 'AGENTS.md' || rel === 'CONTEXT.md' || rel.endsWith('/README.md')) {
return true;
}
return new Set(['.md', '.txt']).has(extname(filePath).toLowerCase());
}
export function chunkText(text, options) {
const maxChars = options.maxChars ?? 1600;
const overlapChars = options.overlapChars ?? 220;
const normalized = text.replace(/\r\n?/gu, '\n').trim();
if (!normalized) {
return [];
}
const blocks = normalized.split(/\n(?=#{1,6}\s+)/u);
const chunks = [];
let current = '';
const pushCurrent = () => {
const trimmed = current.trim();
if (trimmed) {
chunks.push(trimmed);
}
current = '';
};
for (const block of blocks) {
if ((current.length + block.length + 2) <= maxChars) {
current = current ? `${current}\n\n${block}` : block;
continue;
}
pushCurrent();
if (block.length <= maxChars) {
current = block;
continue;
}
for (let start = 0; start < block.length; start += Math.max(1, maxChars - overlapChars)) {
chunks.push(block.slice(start, start + maxChars).trim());
}
}
pushCurrent();
return chunks.map((chunk, index) => ({ index, text: chunk }));
}
export function buildChunkId(filePath, chunkIndex) {
return `${filePath}#${chunkIndex}`;
}
export function extractTitle(text, fallback) {
const title = text.match(/^#\s+(.+)$/mu)?.[1]?.trim();
return title || fallback;
}
export async function createEmbedder(transformers, model) {
const extractor = await transformers.pipeline('feature-extraction', model);
return async function embed(text, type) {
const prefix = type === 'query' ? 'query: ' : 'passage: ';
const output = await extractor(`${prefix}${text}`, {
pooling: 'mean',
normalize: true,
});
return Array.from(output.data, Number);
};
}
export function parseLimitFiles(argv) {
const value = readArg(argv, '--limit-files');
if (!value) {
return Number.POSITIVE_INFINITY;
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Invalid --limit-files value: ${value}`);
}
return parsed;
}
export function readArg(argv, name, fallback = undefined) {
const index = argv.indexOf(name);
if (index === -1) {
return fallback;
}
return argv[index + 1] ?? fallback;
}
export function hasFlag(argv, name) {
return argv.includes(name);
}

195
scripts/rag/search-docs.mjs Normal file
View File

@@ -0,0 +1,195 @@
import { join } from 'node:path';
import {
createEmbedder,
hasFlag,
loadRagRuntime,
readArg,
readConfig,
repoRoot,
} from './rag-utils.mjs';
const config = readConfig();
const query = readArg(process.argv, '--query') ?? process.argv.slice(2).join(' ');
const limit = Number(readArg(process.argv, '--limit', '8'));
const maxChars = Number(readArg(process.argv, '--max-chars', '12000'));
const format = readArg(process.argv, '--format', 'context');
const includeText = !hasFlag(process.argv, '--no-text');
if (!query) {
throw new Error(
'Usage: node scripts/rag/search-docs.mjs --query "搜索内容" [--limit 8] [--format context|json|jsonl|text] [--max-chars 12000]',
);
}
if (!['context', 'json', 'jsonl', 'text'].includes(format)) {
throw new Error(`Unsupported --format value: ${format}`);
}
if (!Number.isFinite(limit) || limit <= 0 || !Number.isInteger(limit)) {
throw new Error(`Invalid --limit value: ${limit}`);
}
if (!Number.isFinite(maxChars) || maxChars <= 0 || !Number.isInteger(maxChars)) {
throw new Error(`Invalid --max-chars value: ${maxChars}`);
}
const { lancedb, transformers } = await loadRagRuntime(config);
const embed = await createEmbedder(transformers, config.model);
const queryVector = await embed(query, 'query');
const db = await lancedb.connect(join(repoRoot, config.databaseDir));
const table = await db.openTable(config.tableName);
const rawResults = await table
.vectorSearch(queryVector)
.select(['id', 'path', 'title', 'chunk_index', 'source_weight', 'text', '_distance'])
.limit(Math.max(limit * 3, limit))
.toArray();
const results = rawResults
.map((row) => ({
...row,
score: (1 / (1 + Number(row._distance ?? 0))) * Number(row.source_weight ?? 1),
}))
.sort((a, b) => b.score - a.score)
.slice(0, limit);
const payload = buildAgentPayload(query, results, {
model: config.model,
tableName: config.tableName,
maxChars,
includeText,
});
if (format === 'json') {
console.log(JSON.stringify(payload, null, 2));
} else if (format === 'jsonl') {
for (const result of payload.results) {
console.log(JSON.stringify(result));
}
} else if (format === 'text') {
printTextResults(payload.results);
} else {
console.log(formatContextPack(payload));
}
function buildAgentPayload(searchQuery, rows, options) {
const outputRows = [];
let remainingChars = options.maxChars;
for (const [index, row] of rows.entries()) {
const source = `${row.path}#${row.chunk_index}`;
const text = String(row.text ?? '').trim();
const result = {
rank: index + 1,
id: row.id,
source,
path: row.path,
title: row.title,
chunkIndex: Number(row.chunk_index),
score: Number(row.score),
distance: Number(row._distance ?? 0),
sourceWeight: Number(row.source_weight ?? 1),
};
if (options.includeText) {
const capped = capText(text, Math.max(0, remainingChars));
result.text = capped.text;
result.truncated = capped.truncated;
remainingChars -= result.text.length;
}
outputRows.push(result);
}
return {
kind: 'genarrative-rag-context',
query: searchQuery,
generatedAt: new Date().toISOString(),
model: options.model,
table: options.tableName,
maxChars: options.maxChars,
remainingChars,
resultCount: outputRows.length,
usage: [
'This context pack is primarily for Agent consumption.',
'Use sources as candidate context and inspect authoritative files before editing when exact line-level changes matter.',
'Prefer docs/project-memory and current docs over stale historical notes when sources conflict.',
],
results: outputRows,
};
}
function capText(text, budget) {
if (budget <= 0) {
return { text: '', truncated: text.length > 0 };
}
if (text.length <= budget) {
return { text, truncated: false };
}
return { text: `${text.slice(0, Math.max(0, budget - 18)).trimEnd()}\n[TRUNCATED]`, truncated: true };
}
function formatContextPack(payload) {
const lines = [
'# Genarrative RAG Context',
'',
`query: ${payload.query}`,
`model: ${payload.model}`,
`results: ${payload.resultCount}`,
`maxChars: ${payload.maxChars}`,
'',
'## Agent Usage',
'',
'- This context pack is primarily for Agent consumption.',
'- Treat sources as candidate context; inspect authoritative files before exact edits.',
'- If sources conflict, prefer current code and current docs over stale historical notes.',
'',
'## Sources',
'',
];
for (const result of payload.results) {
lines.push(
`${result.rank}. ${result.source} score=${result.score.toFixed(4)} distance=${result.distance.toFixed(4)} title=${result.title}`,
);
}
lines.push('', '## Context', '');
for (const result of payload.results) {
const fence = buildMarkdownFence(result.text ?? '');
lines.push(
`### [${result.rank}] ${result.title}`,
'',
`source: ${result.source}`,
`score: ${result.score.toFixed(4)}`,
'',
`${fence}text`,
result.text ?? '',
fence,
'',
);
}
return lines.join('\n');
}
function buildMarkdownFence(text) {
const longest = Math.max(3, ...Array.from(text.matchAll(/`+/gu), (match) => match[0].length));
return '`'.repeat(longest + 1);
}
function printTextResults(rows) {
for (const result of rows) {
const preview = String(result.text ?? '').replace(/\s+/gu, ' ').slice(0, 260);
console.log(
[
`${result.rank}. ${result.source}`,
` title: ${result.title}`,
` score: ${result.score.toFixed(4)} distance: ${result.distance.toFixed(4)}`,
` ${preview}`,
].join('\n'),
);
}
}

56
server-rs/Cargo.lock generated
View File

@@ -4831,9 +4831,9 @@ dependencies = [
[[package]]
name = "spacetimedb"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "536e289684a624421eae421310d2f997a12f1be70e86b3692c87b837cbbb5a33"
checksum = "9fbe68c40e700df6586b1d6a94e52baaa9203d6425b50b0ac5870fe0f543d94d"
dependencies = [
"anyhow",
"bytemuck",
@@ -4854,9 +4854,9 @@ dependencies = [
[[package]]
name = "spacetimedb-bindings-macro"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53256d52b684b899b92b0fbd93f3a654458feb76290893ef13d57900fd38cfd5"
checksum = "3001a940fc424e322f2512ef9a81374ba5da8ea42735ccef7fcce480927bbff1"
dependencies = [
"heck 0.4.1",
"humantime",
@@ -4868,18 +4868,18 @@ dependencies = [
[[package]]
name = "spacetimedb-bindings-sys"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dba2d0109f7f2aa4cf6f349b8145268b214d0348b8e409005452d65b61139080"
checksum = "c418591c1da58ab6cfacdc57077996fe4a101b05fcd06889ab86d1cbc718216a"
dependencies = [
"spacetimedb-primitives",
]
[[package]]
name = "spacetimedb-client-api-messages"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "014a905d52635c0dcb4fde3092fcc001e770d0d34c2e406b837d81196a630423"
checksum = "b3042c18f2b424fc7786b5bd5af59275b903c251ba41d48f11bef28c49f77f73"
dependencies = [
"bytes",
"bytestring",
@@ -4899,9 +4899,9 @@ dependencies = [
[[package]]
name = "spacetimedb-data-structures"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "823d3b3ecac3e8e948f254ee69e3fb848c8c0b4e6f92568bcfdeead1c98c4ff4"
checksum = "c6c1d60cf81d56be3801c0398b701051d9319f6c38e5ec0f9282b29a2c1b2dab"
dependencies = [
"ahash",
"crossbeam-queue",
@@ -4914,9 +4914,9 @@ dependencies = [
[[package]]
name = "spacetimedb-lib"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aecb06dc09f1e964a30f9de87404f21470a10f4f988ef668494b8ea53a4f920"
checksum = "523a8d4a746bb4403fe3e5241e3a72204fc1358e3b118b4f827de7673b6aabcb"
dependencies = [
"anyhow",
"bitflags 2.11.1",
@@ -4939,9 +4939,9 @@ dependencies = [
[[package]]
name = "spacetimedb-memory-usage"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce5f8d17fe9432e0d6b6e04f46001ce0459ef70236e193bd5b17b3f71dd7731"
checksum = "cfa4e78b522fc9ee6e5dbd49c579d42584d6d7d6ce91d02c30471c085265f7df"
dependencies = [
"decorum",
"ethnum",
@@ -4949,9 +4949,9 @@ dependencies = [
[[package]]
name = "spacetimedb-metrics"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d178e28a736a326574c39753107b64a13cac7c04a57948f80caab8cadc1b7d8"
checksum = "226d91f133dcb792dd04ec3870828c4c1d7815a33646e8226894087f1680f8a9"
dependencies = [
"arrayvec",
"itertools",
@@ -4961,9 +4961,9 @@ dependencies = [
[[package]]
name = "spacetimedb-primitives"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2be40c852541973b8faf8c74957ade687579cfc5badd09b1060fb83e9a4fbec8"
checksum = "f625481d6715f9b0aba612599be6c4ab1028ab99425d23d75a268a49628a43f8"
dependencies = [
"bitflags 2.11.1",
"either",
@@ -4975,18 +4975,18 @@ dependencies = [
[[package]]
name = "spacetimedb-query-builder"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "857603c65a283e190b7e0a8bb62c8ff3fbd88cf97b0ae34454862e0caf2a30b7"
checksum = "0cf1d3fb9e170fbbfdf804414ffbb1aa4b4d913684cd68fd5b36ece561479b3c"
dependencies = [
"spacetimedb-lib",
]
[[package]]
name = "spacetimedb-sats"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0290133e753457920bc975872edbb78559cf2003c78d3f2a8e3f2ecc288229d3"
checksum = "449ff63e22853eeaf903563f3cfaf8557ba0c84d0a62abcc388ac429670d321c"
dependencies = [
"anyhow",
"arrayvec",
@@ -5017,9 +5017,9 @@ dependencies = [
[[package]]
name = "spacetimedb-schema"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c54cac9350fe39d35002089af31417d28c85d44eca66646a1515f99117db03e0"
checksum = "99e1d892e7d7fdaa297c565fba749f9a925fc102931187c98976c7c7ad97f80c"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -5048,9 +5048,9 @@ dependencies = [
[[package]]
name = "spacetimedb-sdk"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82ac34a6f244a0b7114ae52c94db0b85ef3e4bddcfa54adcfc8198e0143b638a"
checksum = "115835ba9f558e43781aa6059247157f97f37d1968292bc866a03da906be4258"
dependencies = [
"anymap3",
"base64 0.21.7",
@@ -5080,9 +5080,9 @@ dependencies = [
[[package]]
name = "spacetimedb-sql-parser"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fc9150d2dba445942d1c82b3d6e56f003ca024069eb43d1ccd06c1129ee1294"
checksum = "47a4ab48f838f62e93a593309963862494961339fd60b1555abe48792c6c50bb"
dependencies = [
"derive_more",
"spacetimedb-lib",

View File

@@ -121,9 +121,9 @@ serde_urlencoded = "0.7"
sha1 = "0.10"
sha2 = "0.10"
socket2 = "0.6"
spacetimedb = "=2.4.1"
spacetimedb-sdk = "=2.4.1"
spacetimedb-lib = { version = "=2.4.1", default-features = false }
spacetimedb = "=2.5.0"
spacetimedb-sdk = "=2.5.0"
spacetimedb-lib = { version = "=2.5.0", default-features = false }
time = "0.3"
tokio = "1"
tokio-stream = "0.1"

View File

@@ -56,7 +56,7 @@ shared-kernel = { workspace = true }
shared-logging = { workspace = true }
socket2 = { workspace = true }
spacetime-client = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util", "signal"] }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util", "signal", "process"] }
tokio-stream = { workspace = true }
futures-util = { workspace = true }
time = { workspace = true, features = ["formatting"] }

View File

@@ -122,7 +122,7 @@
当前基础响应头约定:
1. 所有响应都会回写 `x-request-id`
2. 所有响应都会回写固定的 `x-api-version`当前值与 body `meta.apiVersion` 保持一致。
2. 所有响应都会回写固定的 `x-api-version`,值来自 `shared_contracts::api::API_VERSION`,当前为 `2026-06-16`,并与 body `meta.apiVersion` 保持一致。
3. 所有响应都会回写 `x-route-version`,当前阶段默认与 `x-api-version` 保持一致,后续再按路由粒度细分。
4. 所有响应都会回写 `x-response-time-ms`,值来源于 `RequestContext` 内记录的请求开始时间。

View File

@@ -44,6 +44,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::profile::router(state.clone()))
.merge(modules::assets::router(state.clone()))
.merge(modules::platform::router(state.clone()))
.merge(modules::external_generation::router(state.clone()))
.merge(modules::play_flow::router(state.clone()))
.route(
"/api/profile/recharge/wechat/notify",
@@ -485,14 +486,14 @@ mod tests {
.headers()
.get("x-api-version")
.and_then(|value| value.to_str().ok()),
Some("2026-04-08")
Some("2026-06-16")
);
assert_eq!(
response
.headers()
.get("x-route-version")
.and_then(|value| value.to_str().ok()),
Some("2026-04-08")
Some("2026-06-16")
);
assert!(response.headers().contains_key("x-response-time-ms"));

View File

@@ -52,7 +52,7 @@ where
match operation.await {
Ok(value) => Ok(value),
Err(error) => {
if points_consumed {
if points_consumed && should_refund_asset_operation_error(&error) {
refund_asset_operation_points(
state,
owner_user_id,
@@ -67,6 +67,20 @@ where
}
}
pub(crate) fn should_refund_asset_operation_error(error: &AppError) -> bool {
let message = error.body_text();
// 中文注释worker lease guard 拒绝表示当前进程已失去队列写权限;
// 这类 stale worker 失败不能补偿退款,否则可能冲掉后续合法 worker 的同一账本扣费。
!(message.contains("external_generation_job")
&& (message.contains("lease")
|| message.contains("worker")
|| message.contains("job_kind")
|| message.contains("source_")
|| message.contains("owner_user_id")
|| message.contains("不存在")
|| message.contains("不是 running 状态")))
}
/// 资产操作统一预扣泥点;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
async fn consume_asset_operation_points(
state: &AppState,
@@ -249,4 +263,31 @@ mod tests {
&SpacetimeClientError::Procedure("泥点余额不足".to_string()),
));
}
#[test]
fn asset_operation_billing_does_not_refund_stale_worker_lease_errors() {
let stale_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "external_generation_job lease 已过期",
}));
let completed_job_error =
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "external_generation_job 当前不是 running 状态",
}));
let missing_job_error =
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "external_generation_job 不存在",
}));
let ordinary_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "图片生成失败",
}));
assert!(!should_refund_asset_operation_error(&stale_error));
assert!(!should_refund_asset_operation_error(&completed_job_error));
assert!(!should_refund_asset_operation_error(&missing_job_error));
assert!(should_refund_asset_operation_error(&ordinary_error));
}
}

View File

@@ -22,6 +22,19 @@ pub struct AppConfig {
pub bind_port: u16,
pub listen_backlog: i32,
pub worker_threads: Option<usize>,
pub process_role: ProcessRole,
pub external_generation_mode: ExternalGenerationMode,
pub external_generation_worker_id: String,
pub external_generation_worker_concurrency: usize,
pub external_generation_worker_poll_interval: Duration,
pub external_generation_worker_lease: Duration,
pub external_generation_controller_min_workers: usize,
pub external_generation_controller_max_workers: usize,
pub external_generation_controller_target_jobs_per_worker: usize,
pub external_generation_controller_poll_interval: Duration,
pub external_generation_controller_scale_down_idle_rounds: u32,
pub external_generation_controller_service_template: String,
pub external_generation_controller_dry_run: bool,
pub max_concurrent_requests: Option<usize>,
pub gallery_max_concurrent_requests: Option<usize>,
pub detail_max_concurrent_requests: Option<usize>,
@@ -171,6 +184,56 @@ pub struct AppConfig {
pub slow_request_threshold_ms: u64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ProcessRole {
Api,
ExternalGenerationWorker,
ExternalGenerationController,
All,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ExternalGenerationMode {
Inline,
Queue,
}
impl ExternalGenerationMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Inline => "inline",
Self::Queue => "queue",
}
}
pub fn is_inline(self) -> bool {
matches!(self, Self::Inline)
}
}
impl ProcessRole {
pub fn as_str(self) -> &'static str {
match self {
Self::Api => "api",
Self::ExternalGenerationWorker => "external-generation-worker",
Self::ExternalGenerationController => "external-generation-controller",
Self::All => "all",
}
}
pub fn runs_http(self) -> bool {
matches!(self, Self::Api | Self::All)
}
pub fn runs_external_generation_worker(self) -> bool {
matches!(self, Self::ExternalGenerationWorker | Self::All)
}
pub fn runs_external_generation_controller(self) -> bool {
matches!(self, Self::ExternalGenerationController)
}
}
impl Default for AppConfig {
fn default() -> Self {
Self {
@@ -178,6 +241,20 @@ impl Default for AppConfig {
bind_port: 3000,
listen_backlog: 1024,
worker_threads: None,
process_role: ProcessRole::Api,
external_generation_mode: ExternalGenerationMode::Queue,
external_generation_worker_id: default_external_generation_worker_id(),
external_generation_worker_concurrency: 2,
external_generation_worker_poll_interval: Duration::from_millis(2_000),
external_generation_worker_lease: Duration::from_secs(3_600),
external_generation_controller_min_workers: 1,
external_generation_controller_max_workers: 8,
external_generation_controller_target_jobs_per_worker: 2,
external_generation_controller_poll_interval: Duration::from_millis(10_000),
external_generation_controller_scale_down_idle_rounds: 6,
external_generation_controller_service_template:
"genarrative-external-generation-worker@{}.service".to_string(),
external_generation_controller_dry_run: false,
max_concurrent_requests: None,
gallery_max_concurrent_requests: None,
detail_max_concurrent_requests: None,
@@ -374,6 +451,78 @@ impl AppConfig {
if let Some(worker_threads) = read_first_usize_env(&["GENARRATIVE_API_WORKER_THREADS"]) {
config.worker_threads = Some(worker_threads);
}
if let Some(process_role) = read_first_process_role_env(&["GENARRATIVE_PROCESS_ROLE"]) {
config.process_role = process_role;
}
if let Some(external_generation_mode) =
read_first_external_generation_mode_env(&["GENARRATIVE_EXTERNAL_GENERATION_MODE"])
{
config.external_generation_mode = external_generation_mode;
}
if let Some(worker_id) =
read_first_non_empty_env(&["GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID"])
{
config.external_generation_worker_id = worker_id;
}
if let Some(concurrency) =
read_first_usize_env(&["GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY"])
{
config.external_generation_worker_concurrency = concurrency.max(1);
}
if let Some(poll_interval_ms) = read_first_positive_u64_env(&[
"GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS",
]) {
config.external_generation_worker_poll_interval =
Duration::from_millis(poll_interval_ms);
}
if let Some(lease_seconds) = read_first_duration_seconds_env(&[
"GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS",
]) {
config.external_generation_worker_lease = Duration::from_secs(lease_seconds.max(1));
}
if let Some(min_workers) =
read_first_usize_env(&["GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MIN_WORKERS"])
{
config.external_generation_controller_min_workers = min_workers;
}
if let Some(max_workers) =
read_first_usize_env(&["GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MAX_WORKERS"])
{
config.external_generation_controller_max_workers = max_workers;
}
if config.external_generation_controller_max_workers
< config.external_generation_controller_min_workers
{
config.external_generation_controller_max_workers =
config.external_generation_controller_min_workers;
}
if let Some(target_jobs_per_worker) = read_first_usize_env(&[
"GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_TARGET_JOBS_PER_WORKER",
]) {
config.external_generation_controller_target_jobs_per_worker =
target_jobs_per_worker.max(1);
}
if let Some(poll_interval_ms) = read_first_positive_u64_env(&[
"GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_POLL_INTERVAL_MS",
]) {
config.external_generation_controller_poll_interval =
Duration::from_millis(poll_interval_ms);
}
if let Some(idle_rounds) = read_first_u32_env(&[
"GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SCALE_DOWN_IDLE_ROUNDS",
]) {
config.external_generation_controller_scale_down_idle_rounds = idle_rounds;
}
if let Some(service_template) = read_first_non_empty_env(&[
"GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SERVICE_TEMPLATE",
]) {
config.external_generation_controller_service_template = service_template;
}
if let Some(dry_run) =
read_first_bool_env(&["GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_DRY_RUN"])
{
config.external_generation_controller_dry_run = dry_run;
}
if let Some(max_concurrent_requests) =
read_first_usize_env(&["GENARRATIVE_API_MAX_CONCURRENT_REQUESTS"])
{
@@ -1053,6 +1202,22 @@ fn read_first_llm_provider_env(keys: &[&str]) -> Option<LlmProvider> {
})
}
fn read_first_process_role_env(keys: &[&str]) -> Option<ProcessRole> {
keys.iter().find_map(|key| {
env::var(key)
.ok()
.and_then(|value| parse_process_role(&value))
})
}
fn read_first_external_generation_mode_env(keys: &[&str]) -> Option<ExternalGenerationMode> {
keys.iter().find_map(|key| {
env::var(key)
.ok()
.and_then(|value| parse_external_generation_mode(&value))
})
}
fn read_first_positive_u32_env(keys: &[&str]) -> Option<u32> {
keys.iter().find_map(|key| {
env::var(key)
@@ -1100,6 +1265,49 @@ fn read_first_u8_env(keys: &[&str]) -> Option<u8> {
.find_map(|key| env::var(key).ok().and_then(|value| parse_u8(&value)))
}
fn default_external_generation_worker_id() -> String {
let host = env::var("HOSTNAME")
.or_else(|_| env::var("COMPUTERNAME"))
.unwrap_or_else(|_| "local".to_string());
format!("{}-{}", host.trim(), std::process::id())
}
fn parse_process_role(value: &str) -> Option<ProcessRole> {
match trim_quoted_env_value(value).to_ascii_lowercase().as_str() {
"api" => Some(ProcessRole::Api),
"external-generation-worker" | "external_generation_worker" | "worker" => {
Some(ProcessRole::ExternalGenerationWorker)
}
"external-generation-controller" | "external_generation_controller" | "controller" => {
Some(ProcessRole::ExternalGenerationController)
}
"all" => Some(ProcessRole::All),
_ => None,
}
}
fn parse_external_generation_mode(value: &str) -> Option<ExternalGenerationMode> {
match trim_quoted_env_value(value).to_ascii_lowercase().as_str() {
"inline" | "sync" | "synchronous" => Some(ExternalGenerationMode::Inline),
"queue" | "queued" | "worker" | "async" | "asynchronous" => {
Some(ExternalGenerationMode::Queue)
}
_ => None,
}
}
fn trim_quoted_env_value(raw: &str) -> &str {
let raw = raw.trim();
raw.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.or_else(|| {
raw.strip_prefix('\'')
.and_then(|value| value.strip_suffix('\''))
})
.unwrap_or(raw)
.trim()
}
fn read_first_positive_u16_env(keys: &[&str]) -> Option<u16> {
keys.iter().find_map(|key| {
env::var(key)
@@ -1220,7 +1428,8 @@ fn parse_positive_u16(raw: &str) -> Option<u16> {
#[cfg(test)]
mod tests {
use super::{
AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider, parse_bool,
AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, ExternalGenerationMode,
LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role,
};
use std::sync::{Mutex, OnceLock};
@@ -1262,6 +1471,91 @@ mod tests {
assert_eq!(parse_bool("'off'"), Some(false));
}
#[test]
fn process_role_controls_http_and_external_generation_worker_roles() {
assert_eq!(parse_process_role("api"), Some(ProcessRole::Api));
assert_eq!(
parse_process_role("\"external-generation-worker\""),
Some(ProcessRole::ExternalGenerationWorker)
);
assert_eq!(
parse_process_role("'external_generation_worker'"),
Some(ProcessRole::ExternalGenerationWorker)
);
assert_eq!(
parse_process_role("worker"),
Some(ProcessRole::ExternalGenerationWorker)
);
assert_eq!(
parse_process_role("controller"),
Some(ProcessRole::ExternalGenerationController)
);
assert_eq!(
parse_process_role("'external_generation_controller'"),
Some(ProcessRole::ExternalGenerationController)
);
assert_eq!(parse_process_role("all"), Some(ProcessRole::All));
assert_eq!(parse_process_role("unknown"), None);
assert!(ProcessRole::Api.runs_http());
assert!(!ProcessRole::Api.runs_external_generation_worker());
assert!(!ProcessRole::Api.runs_external_generation_controller());
assert!(!ProcessRole::ExternalGenerationWorker.runs_http());
assert!(ProcessRole::ExternalGenerationWorker.runs_external_generation_worker());
assert!(!ProcessRole::ExternalGenerationWorker.runs_external_generation_controller());
assert!(!ProcessRole::ExternalGenerationController.runs_http());
assert!(!ProcessRole::ExternalGenerationController.runs_external_generation_worker());
assert!(ProcessRole::ExternalGenerationController.runs_external_generation_controller());
assert!(ProcessRole::All.runs_http());
assert!(ProcessRole::All.runs_external_generation_worker());
assert!(!ProcessRole::All.runs_external_generation_controller());
}
#[test]
fn external_generation_mode_parses_inline_and_queue_aliases() {
assert_eq!(
parse_external_generation_mode("inline"),
Some(ExternalGenerationMode::Inline)
);
assert_eq!(
parse_external_generation_mode("'sync'"),
Some(ExternalGenerationMode::Inline)
);
assert_eq!(
parse_external_generation_mode("\"queue\""),
Some(ExternalGenerationMode::Queue)
);
assert_eq!(
parse_external_generation_mode("worker"),
Some(ExternalGenerationMode::Queue)
);
assert_eq!(parse_external_generation_mode("unknown"), None);
assert!(ExternalGenerationMode::Inline.is_inline());
assert!(!ExternalGenerationMode::Queue.is_inline());
}
#[test]
fn from_env_reads_external_generation_mode() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock");
unsafe {
std::env::set_var("GENARRATIVE_EXTERNAL_GENERATION_MODE", "inline");
}
let config = AppConfig::from_env();
assert_eq!(
config.external_generation_mode,
ExternalGenerationMode::Inline
);
unsafe {
std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_MODE");
}
}
#[test]
fn from_env_reads_sms_enabled_when_shell_value_keeps_quotes() {
let _guard = ENV_LOCK

View File

@@ -0,0 +1,108 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
response::Response,
};
use serde_json::json;
use shared_contracts::external_generation::{
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
ExternalGenerationJobStatusResponse, ExternalGenerationQueueOverview,
ExternalGenerationQueueOverviewResponse,
};
use spacetime_client::{
ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord,
ExternalGenerationQueueStatsRecord, SpacetimeClientError,
};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
const EXTERNAL_GENERATION_PROVIDER: &str = "external_generation";
pub async fn get_external_generation_queue_overview(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<serde_json::Value>, Response> {
let stats = state
.spacetime_client()
.get_external_generation_queue_stats()
.await
.map_err(|error| external_generation_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
ExternalGenerationQueueOverviewResponse {
overview: map_external_generation_queue_overview(stats),
},
))
}
pub async fn get_external_generation_job_status(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(job_id): Path<String>,
) -> Result<Json<serde_json::Value>, Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
let job = state
.spacetime_client()
.get_external_generation_job(ExternalGenerationJobGetRecordInput {
job_id,
owner_user_id,
})
.await
.map_err(|error| external_generation_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
ExternalGenerationJobStatusResponse {
job: map_external_generation_job_status(job),
},
))
}
fn map_external_generation_queue_overview(
stats: ExternalGenerationQueueStatsRecord,
) -> ExternalGenerationQueueOverview {
ExternalGenerationQueueOverview {
pending_count: stats.pending_count,
running_count: stats.running_active_count,
updated_at_micros: stats.now_micros,
}
}
fn map_external_generation_job_status(
job: ExternalGenerationJobRecord,
) -> ExternalGenerationJobStatusRecord {
let (status, phase_detail, progress) = match job.status.as_str() {
"completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100),
"running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35),
"failed" => (ExternalGenerationJobStatus::Failed, "生成失败。", 0),
_ => (ExternalGenerationJobStatus::Queued, "排队中。", 8),
};
ExternalGenerationJobStatusRecord {
operation_id: job.job_id,
status,
phase_label: job.request_label,
phase_detail: phase_detail.to_string(),
progress,
error: job.last_error_message,
updated_at_micros: job.updated_at_micros,
}
}
fn external_generation_error_response(
request_context: &RequestContext,
error: SpacetimeClientError,
) -> Response {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_details(json!({
"provider": EXTERNAL_GENERATION_PROVIDER,
"message": error.to_string(),
}))
.into_response_with_context(Some(request_context))
}

View File

@@ -0,0 +1,750 @@
use std::{future::Future, io, pin::Pin, time::Duration};
use axum::extract::FromRef;
use serde_json::json;
use shared_kernel::offset_datetime_to_unix_micros;
use spacetime_client::{
ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput,
ExternalGenerationJobFailRecordInput, ExternalGenerationJobRecord,
ExternalGenerationJobRenewLeaseRecordInput,
};
use tokio::{
task::JoinSet,
time::{Instant, sleep},
};
use tracing::{error, info, warn};
use crate::{
jump_hop::{
JUMP_HOP_COMPILE_DRAFT_JOB_KIND, JumpHopCompileDraftWorkerPayload,
execute_jump_hop_compile_draft_worker_job,
},
puzzle::{
ExternalGenerationWriteLeaseGuard, PuzzleCompileDraftWorkerPayload,
PuzzleGenerateImagesWorkerPayload, PuzzleGenerateUiBackgroundWorkerPayload,
execute_puzzle_compile_draft_worker_job, execute_puzzle_generate_images_worker_job,
execute_puzzle_generate_ui_background_worker_job, release_puzzle_compile_background_claim,
},
puzzle_clear::{
PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND, PuzzleClearCompileDraftWorkerPayload,
execute_puzzle_clear_compile_draft_worker_job,
},
request_context::RequestContext,
state::{AppState, PuzzleApiState},
wooden_fish::{
WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND, WoodenFishGenerateImageAssetsWorkerPayload,
execute_wooden_fish_generate_image_assets_worker_job,
},
};
pub(crate) const PUZZLE_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_compile_draft";
pub(crate) const PUZZLE_GENERATE_IMAGES_JOB_KIND: &str = "puzzle_generate_images";
pub(crate) const PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND: &str = "puzzle_generate_ui_background";
pub(crate) async fn run_external_generation_worker(state: AppState) -> Result<(), io::Error> {
let worker_id = state.config.external_generation_worker_id.clone();
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;
let mut tasks = JoinSet::new();
let mut shutdown = external_generation_worker_shutdown_signal();
info!(
worker_id,
concurrency,
poll_interval_ms = poll_interval.as_millis(),
lease_seconds = lease.as_secs(),
"external generation worker 已启动"
);
loop {
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(());
}
}
let available = concurrency.saturating_sub(tasks.len()).max(1);
let now_micros = current_utc_micros();
let lease_expires_at_micros = now_micros.saturating_add(duration_micros_i64(lease));
let claim_jobs = state.spacetime_client().claim_external_generation_jobs(
ExternalGenerationJobClaimRecordInput {
worker_id: worker_id.clone(),
limit: available.min(u32::MAX as usize) as u32,
lease_expires_at_micros,
claimed_at_micros: now_micros,
},
);
tokio::pin!(claim_jobs);
let jobs = match tokio::select! {
_ = shutdown.as_mut() => {
drain_external_generation_worker_tasks(&mut tasks).await;
return Ok(());
}
result = &mut claim_jobs => result
} {
Ok(jobs) => jobs,
Err(error) => {
error!(error = %error, "领取外部生成任务失败,等待下一轮重试");
if await_one_task_or_sleep_or_shutdown(
&mut tasks,
sleep(poll_interval),
&mut shutdown,
)
.await
{
drain_external_generation_worker_tasks(&mut tasks).await;
return Ok(());
}
continue;
}
};
if jobs.is_empty() {
if await_one_task_or_sleep_or_shutdown(&mut tasks, sleep(poll_interval), &mut shutdown)
.await
{
drain_external_generation_worker_tasks(&mut tasks).await;
return Ok(());
}
continue;
}
for job in jobs {
let state = state.clone();
let worker_id = worker_id.clone();
tasks.spawn(async move {
if let Err(error) =
process_external_generation_job(state, worker_id, lease, job).await
{
error!(error = %error, "external generation worker 执行任务失败");
}
});
}
}
}
type ExternalGenerationShutdownSignal = Pin<Box<dyn Future<Output = ()> + Send>>;
fn external_generation_worker_shutdown_signal() -> ExternalGenerationShutdownSignal {
Box::pin(async {
wait_for_external_generation_worker_shutdown_signal().await;
})
}
#[cfg(unix)]
async fn wait_for_external_generation_worker_shutdown_signal() {
use tokio::signal::unix::{SignalKind, signal};
let mut sigterm = signal(SignalKind::terminate()).ok();
tokio::select! {
result = tokio::signal::ctrl_c() => {
if let Err(error) = result {
warn!(error = %error, "external generation worker 监听 SIGINT 失败");
}
}
_ = async {
if let Some(sigterm) = sigterm.as_mut() {
sigterm.recv().await;
} else {
std::future::pending::<()>().await;
}
} => {}
}
}
#[cfg(not(unix))]
async fn wait_for_external_generation_worker_shutdown_signal() {
if let Err(error) = tokio::signal::ctrl_c().await {
warn!(error = %error, "external generation worker 监听 Ctrl-C 失败");
}
}
async fn await_worker_task(tasks: &mut JoinSet<()>) {
if let Some(result) = tasks.join_next().await
&& let Err(error) = result
{
error!(error = %error, "external generation worker 子任务 panic");
}
}
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,
}
}
async fn await_one_task_or_sleep_or_shutdown(
tasks: &mut JoinSet<()>,
sleeper: impl Future<Output = ()>,
shutdown: &mut ExternalGenerationShutdownSignal,
) -> bool {
tokio::pin!(sleeper);
if tasks.is_empty() {
tokio::select! {
_ = shutdown.as_mut() => true,
_ = &mut sleeper => false,
}
} else {
tokio::select! {
_ = shutdown.as_mut() => true,
_ = &mut sleeper => false,
result = tasks.join_next() => {
if let Some(Err(error)) = result {
error!(error = %error, "external generation worker 子任务 panic");
}
false
}
}
}
}
async fn drain_external_generation_worker_tasks(tasks: &mut JoinSet<()>) {
info!(
in_flight_jobs = tasks.len(),
"external generation worker 收到停机信号,停止领取新任务并等待当前任务完成"
);
while !tasks.is_empty() {
await_worker_task(tasks).await;
}
info!("external generation worker 已完成优雅停机");
}
async fn process_external_generation_job(
state: AppState,
worker_id: String,
lease: Duration,
job: ExternalGenerationJobRecord,
) -> Result<(), String> {
let heartbeat_interval = external_generation_worker_heartbeat_interval(lease);
let work = process_external_generation_job_once(state.clone(), worker_id.clone(), job.clone());
tokio::pin!(work);
let heartbeat = sleep(heartbeat_interval);
tokio::pin!(heartbeat);
loop {
tokio::select! {
biased;
result = &mut work => return result,
_ = &mut heartbeat => {
renew_job_lease(&state, &worker_id, &job, lease).await?;
heartbeat.as_mut().reset(Instant::now() + heartbeat_interval);
}
}
}
}
async fn process_external_generation_job_once(
state: AppState,
worker_id: String,
job: ExternalGenerationJobRecord,
) -> Result<(), String> {
match job.job_kind.as_str() {
PUZZLE_COMPILE_DRAFT_JOB_KIND => {
let payload = match serde_json::from_str::<PuzzleCompileDraftWorkerPayload>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("拼图生成任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = RequestContext::new(
format!("external-generation-worker-{}", job.job_id),
format!("external-generation-worker {}", job.job_kind),
std::time::Duration::ZERO,
false,
);
let puzzle_state = PuzzleApiState::from_ref(&state);
let write_guard = build_external_generation_write_lease_guard(&worker_id, &job)?;
match execute_puzzle_compile_draft_worker_job(
&puzzle_state,
&request_context,
payload.clone(),
write_guard,
)
.await
{
Ok(session) => {
let result = complete_job(
&state,
&worker_id,
&job,
Some(
json!({
"sessionId": session.session_id,
"progressPercent": session.progress_percent,
})
.to_string(),
),
)
.await;
if result.is_ok() {
release_puzzle_compile_background_claim(&puzzle_state, &payload);
}
result
}
Err(error) => {
let message = error.body_text();
let should_release_claim = error.should_fail_queue_job();
let result = fail_queue_job_after_worker_error(
&state, &worker_id, &job, &error, &message,
)
.await;
if result.is_ok() && should_release_claim {
release_puzzle_compile_background_claim(&puzzle_state, &payload);
}
result?;
Err(message)
}
}
}
PUZZLE_GENERATE_IMAGES_JOB_KIND => {
let payload = match serde_json::from_str::<PuzzleGenerateImagesWorkerPayload>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("拼图关卡图片生成任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = RequestContext::new(
format!("external-generation-worker-{}", job.job_id),
format!("external-generation-worker {}", job.job_kind),
std::time::Duration::ZERO,
false,
);
let puzzle_state = PuzzleApiState::from_ref(&state);
let write_guard = build_external_generation_write_lease_guard(&worker_id, &job)?;
match execute_puzzle_generate_images_worker_job(
&puzzle_state,
&request_context,
payload,
write_guard,
)
.await
{
Ok(session) => {
complete_job(
&state,
&worker_id,
&job,
Some(
json!({
"sessionId": session.session_id,
"progressPercent": session.progress_percent,
})
.to_string(),
),
)
.await
}
Err(error) => {
let message = error.body_text();
fail_queue_job_after_worker_error(&state, &worker_id, &job, &error, &message)
.await?;
Err(message)
}
}
}
PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND => {
let payload = match serde_json::from_str::<PuzzleGenerateUiBackgroundWorkerPayload>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("拼图 UI 背景图生成任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = RequestContext::new(
format!("external-generation-worker-{}", job.job_id),
format!("external-generation-worker {}", job.job_kind),
std::time::Duration::ZERO,
false,
);
let puzzle_state = PuzzleApiState::from_ref(&state);
let write_guard = build_external_generation_write_lease_guard(&worker_id, &job)?;
match execute_puzzle_generate_ui_background_worker_job(
&puzzle_state,
&request_context,
payload,
write_guard,
)
.await
{
Ok(session) => {
complete_job(
&state,
&worker_id,
&job,
Some(
json!({
"sessionId": session.session_id,
"progressPercent": session.progress_percent,
})
.to_string(),
),
)
.await
}
Err(error) => {
let message = error.body_text();
fail_queue_job_after_worker_error(&state, &worker_id, &job, &error, &message)
.await?;
Err(message)
}
}
}
JUMP_HOP_COMPILE_DRAFT_JOB_KIND => {
let payload = match serde_json::from_str::<JumpHopCompileDraftWorkerPayload>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("跳一跳生成任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = RequestContext::new(
format!("external-generation-worker-{}", job.job_id),
format!("external-generation-worker {}", job.job_kind),
std::time::Duration::ZERO,
false,
);
match execute_jump_hop_compile_draft_worker_job(&state, &request_context, payload).await
{
Ok(session) => {
complete_job(
&state,
&worker_id,
&job,
Some(
json!({
"sessionId": session.session_id,
"status": session.status,
})
.to_string(),
),
)
.await
}
Err(response) => {
let message = response_error_message(response).await;
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND => {
let payload = match serde_json::from_str::<PuzzleClearCompileDraftWorkerPayload>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("拼消消生成任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = RequestContext::new(
format!("external-generation-worker-{}", job.job_id),
format!("external-generation-worker {}", job.job_kind),
std::time::Duration::ZERO,
false,
);
match execute_puzzle_clear_compile_draft_worker_job(&state, &request_context, payload)
.await
{
Ok(session) => {
complete_job(
&state,
&worker_id,
&job,
Some(
json!({
"sessionId": session.session_id,
"status": session.status,
})
.to_string(),
),
)
.await
}
Err(response) => {
let message = response_error_message(response).await;
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND => {
let payload = match serde_json::from_str::<WoodenFishGenerateImageAssetsWorkerPayload>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("敲木鱼图片生成任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = RequestContext::new(
format!("external-generation-worker-{}", job.job_id),
format!("external-generation-worker {}", job.job_kind),
std::time::Duration::ZERO,
false,
);
match execute_wooden_fish_generate_image_assets_worker_job(
&state,
&request_context,
payload,
)
.await
{
Ok(session) => {
complete_job(
&state,
&worker_id,
&job,
Some(
json!({
"sessionId": session.session_id,
"status": session.status,
})
.to_string(),
),
)
.await
}
Err(response) => {
let message = response_error_message(response).await;
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
unknown => {
warn!(
job_id = job.job_id,
job_kind = unknown,
"external generation worker 收到暂不支持的任务类型"
);
fail_job(
&state,
&worker_id,
&job,
format!("暂不支持的外部生成任务类型:{unknown}"),
)
.await
}
}
}
async fn response_error_message(response: axum::response::Response) -> String {
use axum::body::to_bytes;
let status = response.status();
let body_bytes = match to_bytes(response.into_body(), 64 * 1024).await {
Ok(bytes) => bytes,
Err(error) => {
return format!("外部生成任务失败:{status},响应读取失败:{error}");
}
};
let body_text = String::from_utf8_lossy(&body_bytes).trim().to_string();
if body_text.is_empty() {
return format!("外部生成任务失败:{status}");
}
if let Ok(body_json) = serde_json::from_str::<serde_json::Value>(&body_text)
&& let Some(message) = body_json
.get("error")
.and_then(|error| error.get("message"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|message| !message.is_empty())
{
return message.to_string();
}
body_text
}
async fn fail_queue_job_after_worker_error(
state: &AppState,
worker_id: &str,
job: &ExternalGenerationJobRecord,
error: &crate::puzzle::PuzzleExternalGenerationWorkerError,
message: &str,
) -> Result<(), String> {
if error.should_fail_queue_job() {
return fail_job(state, worker_id, job, message.to_string()).await;
}
warn!(
job_id = job.job_id,
job_kind = job.job_kind,
"external generation worker 业务失败态尚未写回,保留任务租约等待后续重试"
);
Ok(())
}
async fn complete_job(
state: &AppState,
worker_id: &str,
job: &ExternalGenerationJobRecord,
result_payload_json: Option<String>,
) -> Result<(), String> {
state
.spacetime_client()
.complete_external_generation_job(ExternalGenerationJobCompleteRecordInput {
job_id: job.job_id.clone(),
worker_id: worker_id.to_string(),
lease_token: require_job_lease_token(job)?,
result_payload_json,
completed_at_micros: current_utc_micros(),
})
.await
.map(|_| ())
.map_err(|error| error.to_string())
}
async fn fail_job(
state: &AppState,
worker_id: &str,
job: &ExternalGenerationJobRecord,
error_message: String,
) -> Result<(), String> {
let now_micros = current_utc_micros();
state
.spacetime_client()
.fail_external_generation_job(ExternalGenerationJobFailRecordInput {
job_id: job.job_id.clone(),
worker_id: worker_id.to_string(),
lease_token: require_job_lease_token(job)?,
error_message,
retry_after_micros: now_micros.saturating_add(60_000_000),
failed_at_micros: now_micros,
})
.await
.map(|_| ())
.map_err(|error| error.to_string())
}
async fn renew_job_lease(
state: &AppState,
worker_id: &str,
job: &ExternalGenerationJobRecord,
lease: Duration,
) -> Result<(), String> {
let now_micros = current_utc_micros();
state
.spacetime_client()
.renew_external_generation_job_lease(ExternalGenerationJobRenewLeaseRecordInput {
job_id: job.job_id.clone(),
worker_id: worker_id.to_string(),
lease_token: require_job_lease_token(job)?,
lease_expires_at_micros: now_micros.saturating_add(duration_micros_i64(lease)),
renewed_at_micros: now_micros,
})
.await
.map(|_| ())
.map_err(|error| error.to_string())
}
fn require_job_lease_token(job: &ExternalGenerationJobRecord) -> Result<String, String> {
job.lease_token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| format!("external_generation_job {} 缺少 lease token", job.job_id))
}
fn build_external_generation_write_lease_guard(
worker_id: &str,
job: &ExternalGenerationJobRecord,
) -> Result<ExternalGenerationWriteLeaseGuard, String> {
Ok(ExternalGenerationWriteLeaseGuard::from_claimed_job(
job.job_id.clone(),
worker_id.to_string(),
require_job_lease_token(job)?,
))
}
fn duration_micros_i64(duration: Duration) -> i64 {
duration.as_micros().min(i64::MAX as u128) as i64
}
fn external_generation_worker_heartbeat_interval(lease: Duration) -> Duration {
let heartbeat_millis = (lease.as_millis() / 3).clamp(250, 30_000) as u64;
Duration::from_millis(heartbeat_millis)
}
fn current_utc_micros() -> i64 {
offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn worker_write_guard_uses_claimed_job_lease_token() {
let job = external_generation_job_record_fixture(Some("lease-1"));
let guard = build_external_generation_write_lease_guard("worker-a", &job)
.expect("guard should build");
assert_eq!(guard.job_id.as_deref(), Some("extgen-1"));
assert_eq!(guard.worker_id.as_deref(), Some("worker-a"));
assert_eq!(guard.lease_token.as_deref(), Some("lease-1"));
}
#[test]
fn worker_write_guard_requires_claimed_job_lease_token() {
let job = external_generation_job_record_fixture(None);
let error = build_external_generation_write_lease_guard("worker-a", &job)
.expect_err("missing token should fail");
assert!(error.contains("缺少 lease token"));
}
fn external_generation_job_record_fixture(
lease_token: Option<&str>,
) -> ExternalGenerationJobRecord {
ExternalGenerationJobRecord {
job_id: "extgen-1".to_string(),
dedupe_key: "puzzle:generate_puzzle_images:session-1:extgen-1".to_string(),
job_kind: PUZZLE_GENERATE_IMAGES_JOB_KIND.to_string(),
owner_user_id: "user-1".to_string(),
source_module: "puzzle".to_string(),
source_entity_id: "session-1:puzzle-level-1".to_string(),
request_label: "拼图关卡图片生成".to_string(),
request_payload_json: "{}".to_string(),
status: "running".to_string(),
attempt: 1,
max_attempts: 1,
last_error_message: None,
worker_id: Some("worker-a".to_string()),
lease_expires_at: Some("2026-06-03T00:00:00Z".to_string()),
available_at: "2026-06-03T00:00:00Z".to_string(),
result_payload_json: None,
created_at: "2026-06-03T00:00:00Z".to_string(),
started_at: Some("2026-06-03T00:00:00Z".to_string()),
completed_at: None,
updated_at: "2026-06-03T00:00:00Z".to_string(),
lease_token: lease_token.map(ToOwned::to_owned),
}
}
}

View File

@@ -0,0 +1,465 @@
use std::{collections::BTreeSet, future::Future, io, pin::Pin, process::Stdio, time::Duration};
use spacetime_client::ExternalGenerationQueueStatsRecord;
use tokio::{
process::Command,
time::{Instant, sleep},
};
use tracing::{error, info, warn};
use crate::state::AppState;
#[derive(Clone, Debug)]
struct ExternalGenerationWorkerControllerConfig {
min_workers: usize,
max_workers: usize,
target_jobs_per_worker: usize,
poll_interval: Duration,
scale_down_idle_rounds: u32,
service_template: String,
dry_run: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ExternalGenerationWorkerControllerDecision {
desired_workers: usize,
should_scale_down: bool,
idle_rounds: u32,
}
#[derive(Debug, Default)]
struct ExternalGenerationWorkerControllerState {
idle_rounds: u32,
}
pub(crate) async fn run_external_generation_worker_controller(
state: AppState,
) -> Result<(), io::Error> {
let config = ExternalGenerationWorkerControllerConfig::from_state(&state);
let mut controller_state = ExternalGenerationWorkerControllerState::default();
let mut shutdown = external_generation_controller_shutdown_signal();
info!(
min_workers = config.min_workers,
max_workers = config.max_workers,
target_jobs_per_worker = config.target_jobs_per_worker,
poll_interval_ms = config.poll_interval.as_millis(),
scale_down_idle_rounds = config.scale_down_idle_rounds,
service_template = config.service_template,
dry_run = config.dry_run,
"external generation worker controller 已启动"
);
loop {
let tick = run_external_generation_controller_tick(&state, &config, &mut controller_state);
tokio::select! {
_ = shutdown.as_mut() => {
info!("external generation worker controller 收到停机信号");
return Ok(());
}
result = tick => {
if let Err(error) = result {
error!(error = %error, "external generation worker controller 本轮扩缩容失败");
}
}
}
let next_tick = sleep(config.poll_interval);
tokio::pin!(next_tick);
tokio::select! {
_ = shutdown.as_mut() => {
info!("external generation worker controller 收到停机信号");
return Ok(());
}
_ = &mut next_tick => {}
}
}
}
async fn run_external_generation_controller_tick(
state: &AppState,
config: &ExternalGenerationWorkerControllerConfig,
controller_state: &mut ExternalGenerationWorkerControllerState,
) -> Result<(), String> {
let stats = state
.spacetime_client()
.get_external_generation_queue_stats()
.await
.map_err(|error| format!("读取 external_generation_job 队列统计失败:{error}"))?;
let active_instances = list_active_external_generation_worker_instances(config).await?;
let current_workers = active_instances.len();
let decision = decide_external_generation_worker_target(
&stats,
current_workers,
controller_state.idle_rounds,
config,
);
controller_state.idle_rounds = decision.idle_rounds;
info!(
pending = stats.pending_count,
delayed_pending = stats.delayed_pending_count,
claimable = stats.claimable_count,
running_active = stats.running_active_count,
expired_running = stats.expired_running_count,
oldest_claimable_age_ms = stats.oldest_claimable_age_micros.unwrap_or(0) / 1_000,
current_workers,
desired_workers = decision.desired_workers,
idle_rounds = decision.idle_rounds,
"external generation worker controller 完成队列评估"
);
reconcile_external_generation_worker_instances(config, &active_instances, &decision).await
}
fn decide_external_generation_worker_target(
stats: &ExternalGenerationQueueStatsRecord,
current_workers: usize,
previous_idle_rounds: u32,
config: &ExternalGenerationWorkerControllerConfig,
) -> ExternalGenerationWorkerControllerDecision {
let pressure = stats
.claimable_pending_count
.saturating_add(stats.running_active_count)
.saturating_add(stats.expired_running_count);
let desired_from_pressure =
ceil_div_usize(pressure as usize, config.target_jobs_per_worker.max(1));
let desired_workers = desired_from_pressure.clamp(config.min_workers, config.max_workers);
let is_idle = stats.claimable_count == 0
&& stats.expired_running_count == 0
&& stats.running_active_count == 0
&& desired_workers <= config.min_workers;
let idle_rounds = if is_idle {
previous_idle_rounds.saturating_add(1)
} else {
0
};
let should_scale_down = current_workers > desired_workers
&& idle_rounds >= config.scale_down_idle_rounds
&& config.scale_down_idle_rounds > 0;
ExternalGenerationWorkerControllerDecision {
desired_workers,
should_scale_down,
idle_rounds,
}
}
async fn reconcile_external_generation_worker_instances(
config: &ExternalGenerationWorkerControllerConfig,
active_instances: &BTreeSet<usize>,
decision: &ExternalGenerationWorkerControllerDecision,
) -> Result<(), String> {
let current_workers = active_instances.len();
let mut started = 0usize;
for instance in 1..=config.max_workers {
if current_workers.saturating_add(started) >= decision.desired_workers {
break;
}
if !active_instances.contains(&instance) {
systemctl_worker_instance(config, "start", instance).await?;
started = started.saturating_add(1);
}
}
if decision.desired_workers > current_workers && started == 0 {
warn!(
current_workers,
desired_workers = decision.desired_workers,
"external generation worker controller 未找到可启动的缺口实例"
);
}
if started > 0 {
return Ok(());
}
if decision.should_scale_down && decision.desired_workers < current_workers {
if let Some(instance) = active_instances
.iter()
.rev()
.copied()
.find(|instance| *instance > config.min_workers.max(1))
{
systemctl_worker_instance(config, "stop", instance).await?;
}
}
Ok(())
}
async fn list_active_external_generation_worker_instances(
config: &ExternalGenerationWorkerControllerConfig,
) -> Result<BTreeSet<usize>, String> {
let mut active_instances = BTreeSet::new();
for instance in 1..=config.max_workers {
if is_external_generation_worker_instance_active(config, instance).await? {
active_instances.insert(instance);
}
}
Ok(active_instances)
}
async fn is_external_generation_worker_instance_active(
config: &ExternalGenerationWorkerControllerConfig,
instance: usize,
) -> Result<bool, String> {
let service = format_worker_service_name(&config.service_template, instance)?;
if config.dry_run {
return Ok(instance <= config.min_workers);
}
let output = Command::new("systemctl")
.arg("is-active")
.arg("--quiet")
.arg(&service)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.output()
.await
.map_err(|error| format!("执行 systemctl is-active {service} 失败:{error}"))?;
Ok(output.status.success())
}
async fn systemctl_worker_instance(
config: &ExternalGenerationWorkerControllerConfig,
action: &str,
instance: usize,
) -> Result<(), String> {
let service = format_worker_service_name(&config.service_template, instance)?;
if config.dry_run {
info!(
action,
service, "external generation worker controller dry-run 跳过 systemctl"
);
return Ok(());
}
let started_at = Instant::now();
let output = Command::new("systemctl")
.arg(action)
.arg(&service)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|error| format!("执行 systemctl {action} {service} 失败:{error}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"systemctl {action} {service} 返回失败 status={} stderr={}",
output.status, stderr
));
}
info!(
action,
service,
elapsed_ms = started_at.elapsed().as_millis(),
"external generation worker controller 已执行 systemctl"
);
Ok(())
}
fn format_worker_service_name(template: &str, instance: usize) -> Result<String, String> {
let instance = instance.to_string();
if template.contains("{}") {
return Ok(template.replacen("{}", &instance, 1));
}
if template.contains("%i") {
return Ok(template.replacen("%i", &instance, 1));
}
Err("external generation controller service template 必须包含 {} 或 %i".to_string())
}
fn ceil_div_usize(value: usize, divisor: usize) -> usize {
if value == 0 {
0
} else {
value.saturating_add(divisor.saturating_sub(1)) / divisor.max(1)
}
}
impl ExternalGenerationWorkerControllerConfig {
fn from_state(state: &AppState) -> Self {
let min_workers = state.config.external_generation_controller_min_workers;
let max_workers = state
.config
.external_generation_controller_max_workers
.max(min_workers);
Self {
min_workers,
max_workers,
target_jobs_per_worker: state
.config
.external_generation_controller_target_jobs_per_worker
.max(1),
poll_interval: state.config.external_generation_controller_poll_interval,
scale_down_idle_rounds: state
.config
.external_generation_controller_scale_down_idle_rounds,
service_template: state
.config
.external_generation_controller_service_template
.clone(),
dry_run: state.config.external_generation_controller_dry_run,
}
}
}
type ExternalGenerationControllerShutdownSignal = Pin<Box<dyn Future<Output = ()> + Send>>;
fn external_generation_controller_shutdown_signal() -> ExternalGenerationControllerShutdownSignal {
Box::pin(async {
wait_for_external_generation_controller_shutdown_signal().await;
})
}
#[cfg(unix)]
async fn wait_for_external_generation_controller_shutdown_signal() {
use tokio::signal::unix::{SignalKind, signal};
let mut sigterm = signal(SignalKind::terminate()).ok();
tokio::select! {
result = tokio::signal::ctrl_c() => {
if let Err(error) = result {
warn!(error = %error, "external generation worker controller 监听 SIGINT 失败");
}
}
_ = async {
if let Some(sigterm) = sigterm.as_mut() {
sigterm.recv().await;
} else {
std::future::pending::<()>().await;
}
} => {}
}
}
#[cfg(not(unix))]
async fn wait_for_external_generation_controller_shutdown_signal() {
if let Err(error) = tokio::signal::ctrl_c().await {
warn!(error = %error, "external generation worker controller 监听 Ctrl-C 失败");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scales_up_to_max_when_queue_pressure_is_high() {
let config = controller_config_fixture();
let stats = stats_fixture(120, 0, 8);
let decision = decide_external_generation_worker_target(&stats, 1, 0, &config);
assert_eq!(decision.desired_workers, 8);
assert!(!decision.should_scale_down);
assert_eq!(decision.idle_rounds, 0);
}
#[test]
fn scale_down_requires_consecutive_idle_rounds() {
let config = controller_config_fixture();
let stats = stats_fixture(0, 0, 0);
let first = decide_external_generation_worker_target(&stats, 5, 0, &config);
let ready = decide_external_generation_worker_target(
&stats,
5,
config.scale_down_idle_rounds.saturating_sub(1),
&config,
);
assert_eq!(first.desired_workers, config.min_workers);
assert!(!first.should_scale_down);
assert!(ready.should_scale_down);
}
#[test]
fn running_jobs_hold_capacity_before_scale_down() {
let config = controller_config_fixture();
let stats = stats_fixture(0, 6, 0);
let decision = decide_external_generation_worker_target(&stats, 5, 5, &config);
assert_eq!(decision.desired_workers, 3);
assert!(!decision.should_scale_down);
assert_eq!(decision.idle_rounds, 0);
}
#[test]
fn expired_running_jobs_are_not_counted_twice_as_claimable_pressure() {
let config = controller_config_fixture();
let stats = stats_fixture(0, 0, 3);
let decision = decide_external_generation_worker_target(&stats, 1, 0, &config);
assert_eq!(decision.desired_workers, 2);
assert!(!decision.should_scale_down);
}
#[test]
fn formats_worker_service_name_with_supported_templates() {
assert_eq!(
format_worker_service_name("genarrative-external-generation-worker@{}.service", 3)
.expect("format"),
"genarrative-external-generation-worker@3.service"
);
assert_eq!(
format_worker_service_name("worker@%i.service", 7).expect("format"),
"worker@7.service"
);
assert!(format_worker_service_name("worker.service", 1).is_err());
}
#[tokio::test]
async fn dry_run_reconcile_does_not_start_low_number_gaps_when_capacity_is_enough() {
let config = controller_config_fixture();
let active_instances = BTreeSet::from([3usize, 4usize]);
let decision = ExternalGenerationWorkerControllerDecision {
desired_workers: 2,
should_scale_down: false,
idle_rounds: 0,
};
let result =
reconcile_external_generation_worker_instances(&config, &active_instances, &decision)
.await;
assert!(result.is_ok());
}
fn controller_config_fixture() -> ExternalGenerationWorkerControllerConfig {
ExternalGenerationWorkerControllerConfig {
min_workers: 1,
max_workers: 8,
target_jobs_per_worker: 2,
poll_interval: Duration::from_secs(10),
scale_down_idle_rounds: 3,
service_template: "genarrative-external-generation-worker@{}.service".to_string(),
dry_run: true,
}
}
fn stats_fixture(
claimable_pending_count: u32,
running_active_count: u32,
expired_running_count: u32,
) -> ExternalGenerationQueueStatsRecord {
let claimable_count = claimable_pending_count.saturating_add(expired_running_count);
ExternalGenerationQueueStatsRecord {
pending_count: claimable_pending_count,
delayed_pending_count: 0,
claimable_pending_count,
running_active_count,
expired_running_count,
terminal_count: 0,
claimable_count,
oldest_claimable_age_micros: None,
now_micros: 0,
}
}
}

View File

@@ -9,7 +9,11 @@ use module_assets::{
generate_asset_binding_id, generate_asset_object_id,
};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_contracts::external_generation::{
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
};
use shared_contracts::jump_hop::{
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
@@ -20,7 +24,9 @@ use shared_contracts::jump_hop::{
JumpHopWorksResponse, JumpHopWorkspaceCreateRequest,
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError;
use spacetime_client::{
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, SpacetimeClientError,
};
use std::{
collections::BTreeMap,
time::{SystemTime, UNIX_EPOCH},
@@ -49,6 +55,7 @@ use crate::{
};
const JUMP_HOP_TILE_ITEM_COUNT: usize = 18;
pub(crate) const JUMP_HOP_COMPILE_DRAFT_JOB_KIND: &str = "jump_hop_compile_draft";
const JUMP_HOP_PROVIDER: &str = "jump-hop";
const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation";
@@ -72,6 +79,14 @@ const JUMP_HOP_BACK_BUTTON_IMAGE_SIZE: &str = "1024*1024";
const JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH: u32 = 1024;
const JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT: u32 = 1024;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct JumpHopCompileDraftWorkerPayload {
pub session_id: String,
pub owner_user_id: String,
pub payload: JumpHopActionRequest,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct JumpHopTileAtlasSlice {
tile_type: JumpHopTileType,
@@ -174,6 +189,37 @@ pub async fn execute_jump_hop_action(
let owner_user_id = authenticated.claims().user_id().to_string();
let mut payload = payload;
let is_compile_draft = matches!(payload.action_type, JumpHopActionType::CompileDraft);
let should_queue_generation = matches!(
payload.action_type,
JumpHopActionType::CompileDraft | JumpHopActionType::RegenerateTiles
) && !state.config.external_generation_mode.is_inline();
if should_queue_generation {
let mut queued_response = state
.spacetime_client()
.mark_jump_hop_generation_queued(
session_id.clone(),
owner_user_id.clone(),
payload.clone(),
)
.await
.map_err(|error| {
jump_hop_error_response(
&request_context,
JUMP_HOP_CREATION_PROVIDER,
map_jump_hop_client_error(error),
)
})?;
let queue_job = enqueue_jump_hop_compile_draft_job(
&state,
&request_context,
&session_id,
owner_user_id.as_str(),
payload,
)
.await?;
queued_response.queue_state = Some(map_jump_hop_queue_job_status(queue_job));
return Ok(json_success_body(Some(&request_context), queued_response));
}
let generation_points_cost = if is_compile_draft {
resolve_jump_hop_generation_points_cost(&state).await
} else {
@@ -246,6 +292,99 @@ pub async fn execute_jump_hop_action(
}
}
async fn enqueue_jump_hop_compile_draft_job(
state: &AppState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
payload: JumpHopActionRequest,
) -> Result<ExternalGenerationJobRecord, Response> {
let job_id = build_prefixed_uuid_id("extgen-");
let now_micros = current_utc_micros();
let request_payload_json = serde_json::to_string(&JumpHopCompileDraftWorkerPayload {
session_id: session_id.to_string(),
owner_user_id: owner_user_id.to_string(),
payload,
})
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"message": format!("跳一跳 worker 任务参数序列化失败:{error}"),
})),
)
})?;
state
.spacetime_client()
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
dedupe_key: format!("jump-hop:compile-draft:{session_id}:{job_id}"),
job_id,
job_kind: JUMP_HOP_COMPILE_DRAFT_JOB_KIND.to_string(),
owner_user_id: owner_user_id.to_string(),
source_module: "jump-hop".to_string(),
source_entity_id: session_id.to_string(),
request_label: "跳一跳草稿生成".to_string(),
request_payload_json,
max_attempts: 1,
available_at_micros: now_micros,
created_at_micros: now_micros,
})
.await
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
map_jump_hop_client_error(error),
)
})
}
fn map_jump_hop_queue_job_status(
job: ExternalGenerationJobRecord,
) -> ExternalGenerationJobStatusRecord {
ExternalGenerationJobStatusRecord {
operation_id: job.job_id,
status: ExternalGenerationJobStatus::Queued,
phase_label: job.request_label,
phase_detail: "排队中。".to_string(),
progress: 8,
error: job.last_error_message,
updated_at_micros: job.updated_at_micros,
}
}
pub(crate) async fn execute_jump_hop_compile_draft_worker_job(
state: &AppState,
request_context: &RequestContext,
mut worker_payload: JumpHopCompileDraftWorkerPayload,
) -> Result<JumpHopSessionSnapshotResponse, Response> {
maybe_generate_jump_hop_assets(
state,
request_context,
worker_payload.session_id.as_str(),
worker_payload.owner_user_id.as_str(),
&mut worker_payload.payload,
)
.await?;
let response = state
.spacetime_client()
.execute_jump_hop_action(
worker_payload.session_id,
worker_payload.owner_user_id,
worker_payload.payload,
)
.await
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
map_jump_hop_client_error(error),
)
})?;
Ok(response.session)
}
async fn resolve_jump_hop_generation_points_cost(state: &AppState) -> u64 {
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state,
@@ -1005,15 +1144,8 @@ fn slice_jump_hop_tile_atlas(
let y1 = (row.saturating_add(1)).saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS;
let tile_width = x1.saturating_sub(x0).max(1);
let tile_height = y1.saturating_sub(y0).max(1);
let faces = slice_jump_hop_tile_uv_faces(
&source,
x0,
y0,
tile_width,
tile_height,
row,
col,
)?;
let faces =
slice_jump_hop_tile_uv_faces(&source, x0, y0, tile_width, tile_height, row, col)?;
slices.push(JumpHopTileAtlasSlice {
tile_type: jump_hop_tile_type_by_index(index),
source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1),
@@ -1043,22 +1175,70 @@ fn slice_jump_hop_tile_uv_faces(
Ok(JumpHopTileFaceSlices {
top: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Top, 1, 0,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Top,
1,
0,
)?,
front: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Front, 1, 1,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Front,
1,
1,
)?,
right: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Right, 2, 1,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Right,
2,
1,
)?,
back: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Back, 3, 1,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Back,
3,
1,
)?,
left: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Left, 0, 1,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Left,
0,
1,
)?,
bottom: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Bottom, 1, 2,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Bottom,
1,
2,
)?,
})
}
@@ -1095,12 +1275,7 @@ fn slice_jump_hop_tile_uv_face(
Ok(JumpHopTileFaceSlice {
face,
source_atlas_cell: format!(
"row-{}-col-{}/{}",
atlas_row + 1,
atlas_col + 1,
face_label
),
source_atlas_cell: format!("row-{}-col-{}/{}", atlas_row + 1, atlas_col + 1, face_label),
bytes: cursor.into_inner(),
})
}
@@ -1827,7 +2002,9 @@ mod tests {
assert!(prompt.contains("18个用于跳一跳地板的立方体主题物体 UV 展开包装图"));
assert!(prompt.contains("按三列六行均匀排布"));
assert!(prompt.contains("每个大单元格代表一个完整的 1x1x1 立方体方块物体"));
assert!(prompt.contains("该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上"));
assert!(
prompt.contains("该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上")
);
assert!(prompt.contains("cube object UV unwrap atlas / 立方体主题物体六面展开图集"));
assert!(prompt.contains("不是单纯平铺材质、不是抽象纹理、不是只把主题颜色铺满"));
assert!(prompt.contains("游戏界面或图标集页面"));
@@ -1850,7 +2027,9 @@ mod tests {
assert!(prompt.contains("full-bleed opaque square face texture"));
assert!(prompt.contains("四角、边缘和中心都要有可识别内容"));
assert!(prompt.contains("不留透明、不留空白、不留实底背景"));
assert!(prompt.contains("允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点"));
assert!(prompt.contains(
"允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点"
));
assert!(prompt.contains("不要把一个小水果、小叶片、小石头或小物体放在面中央"));
assert!(prompt.contains("这不是透视渲染图"));
assert!(prompt.contains("不要画摄像机视角、透视块、已烘焙侧壁"));
@@ -1868,14 +2047,18 @@ mod tests {
assert!(prompt.contains("小贴纸图标、小物体居中、纯果皮材质、纯果肉纹理"));
assert!(prompt.contains("English guardrail"));
assert!(prompt.contains("one vertical 1024x1536 image"));
assert!(prompt.contains("exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas"));
assert!(
prompt.contains("exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas")
);
assert!(prompt.contains("row1 col2 top"));
assert!(prompt.contains("row2 col1 left"));
assert!(prompt.contains("row2 col2 front"));
assert!(prompt.contains("row2 col3 right"));
assert!(prompt.contains("row2 col4 back"));
assert!(prompt.contains("row3 col2 bottom"));
assert!(prompt.contains("six different face textures that stitch into one recognizable cubified theme object"));
assert!(prompt.contains(
"six different face textures that stitch into one recognizable cubified theme object"
));
assert!(prompt.contains("no generic flat material"));
assert!(prompt.contains("no small centered stickers"));
assert!(prompt.contains("every face is full-bleed opaque square texture"));
@@ -2022,7 +2205,9 @@ mod tests {
"科幻芯片主题的俯视角清爽游戏化立体感平台素材",
);
assert!(prompt.contains("画面内容是科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图"));
assert!(
prompt.contains("画面内容是科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图")
);
assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角清爽游戏化立体感平台素材"));
assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角"));
@@ -2118,12 +2303,10 @@ mod tests {
.max(1);
let tile_x = atlas_col.saturating_mul(cell_width);
let tile_y = atlas_row.saturating_mul(cell_height);
let uv_x = tile_x.saturating_add(
cell_width.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2,
);
let uv_y = tile_y.saturating_add(
cell_height.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2,
);
let uv_x = tile_x
.saturating_add(cell_width.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2);
let uv_y = tile_y
.saturating_add(cell_height.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2);
for y in uv_y + face_row * face_side..uv_y + (face_row + 1) * face_side {
for x in uv_x + face_col * face_side..uv_x + (face_col + 1) * face_side {
atlas.put_pixel(x, y, color);
@@ -2159,14 +2342,8 @@ mod tests {
),
"{message}"
);
assert!(
decoded.pixels().any(|pixel| pixel.0 == color),
"{message}"
);
assert!(
decoded.pixels().all(|pixel| pixel.0[3] == 255),
"{message}"
);
assert!(decoded.pixels().any(|pixel| pixel.0 == color), "{message}");
assert!(decoded.pixels().all(|pixel| pixel.0[3] == 255), "{message}");
}
#[test]

View File

@@ -40,6 +40,9 @@ mod edutainment_baby_drawing;
mod edutainment_baby_object;
mod error_middleware;
mod external_api_audit;
mod external_generation;
mod external_generation_worker;
mod external_generation_worker_controller;
pub(crate) mod generated_asset_sheets;
mod generated_image_assets;
mod health;
@@ -114,6 +117,8 @@ use tracing::{error, info, warn};
use crate::{
app::{build_router, build_spacetime_unavailable_router},
config::AppConfig,
external_generation_worker::run_external_generation_worker,
external_generation_worker_controller::run_external_generation_worker_controller,
state::{AppState, AppStateInitError},
tracking_outbox::TrackingOutbox,
wallet_refund_outbox::WalletRefundOutbox,
@@ -167,24 +172,57 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
process_metrics::register_process_metrics();
telemetry::register_http_runtime_metrics();
if !config.process_role.runs_http() {
return run_worker_only(config).await;
}
run_http_role(config).await
}
async fn run_worker_only(config: AppConfig) -> Result<(), io::Error> {
let process_role = config.process_role;
let state = restore_app_state_for_startup(config)
.await
.map_err(|error| {
io::Error::other(format!(
"初始化 external generation worker 状态失败:{error}"
))
})?;
spawn_app_state_background_workers(&state);
info!(
process_role = process_role.as_str(),
"api-server 以非 HTTP 角色启动"
);
if process_role.runs_external_generation_worker() {
run_external_generation_worker(state).await
} else if process_role.runs_external_generation_controller() {
run_external_generation_worker_controller(state).await
} else {
Err(io::Error::other(format!(
"不支持的非 HTTP 进程角色:{}",
process_role.as_str()
)))
}
}
async fn run_http_role(config: AppConfig) -> Result<(), io::Error> {
let bind_address = config.bind_socket_addr();
let listen_backlog = config.listen_backlog;
let worker_threads = config.worker_threads;
let otel_enabled = config.otel_enabled;
let process_role = config.process_role;
let outbox_flush_timeout = config.shutdown_outbox_flush_timeout;
let listener = build_tcp_listener(bind_address, listen_backlog)?;
let (router, shutdown_context) = match restore_app_state_for_startup(config).await {
let (router, shutdown_context, worker_state) = match restore_app_state_for_startup(config).await
{
Ok(state) => {
state.puzzle_gallery_cache().spawn_cleanup_task();
spawn_app_state_background_workers(&state);
let tracking_outbox = state.tracking_outbox();
if let Some(outbox) = tracking_outbox.clone() {
outbox.spawn_worker();
}
let wallet_refund_outbox = state.wallet_refund_outbox();
if let Some(outbox) = wallet_refund_outbox.clone() {
outbox.spawn_worker();
}
let worker_state = process_role
.runs_external_generation_worker()
.then(|| state.clone());
(
build_router(state.clone()),
ShutdownContext {
@@ -193,6 +231,7 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
wallet_refund_outbox,
outbox_flush_timeout,
},
worker_state,
)
}
Err(AppStateInitError::DependencyUnavailable(message)) => (
@@ -203,6 +242,7 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
wallet_refund_outbox: None,
outbox_flush_timeout,
},
None,
),
Err(error) => {
return Err(std::io::Error::other(format!(
@@ -216,12 +256,20 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
listen_backlog,
worker_threads = worker_threads.unwrap_or(0),
otel_enabled,
process_role = process_role.as_str(),
"api-server 已完成 tracing 初始化并开始监听"
);
let result = axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal(shutdown_context.clone()))
.await;
let http_server = axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal(shutdown_context.clone()));
let result = if let Some(worker_state) = worker_state {
tokio::select! {
result = http_server => result,
result = run_external_generation_worker(worker_state) => result,
}
} else {
http_server.await
};
finalize_shutdown(shutdown_context).await;
result
}
@@ -332,6 +380,16 @@ async fn finalize_shutdown(context: ShutdownContext) {
}
}
fn spawn_app_state_background_workers(state: &AppState) {
state.puzzle_gallery_cache().spawn_cleanup_task();
if let Some(outbox) = state.tracking_outbox() {
outbox.spawn_worker();
}
if let Some(outbox) = state.wallet_refund_outbox() {
outbox.spawn_worker();
}
}
fn build_tcp_listener(
bind_address: SocketAddr,
listen_backlog: i32,

View File

@@ -5,6 +5,7 @@ pub mod bark_battle;
pub mod big_fish;
pub mod custom_world;
pub mod edutainment;
pub mod external_generation;
pub mod health;
pub mod internal;
pub mod jump_hop;

View File

@@ -0,0 +1,26 @@
use axum::{Router, middleware, routing::get};
use crate::{
auth::require_bearer_auth,
external_generation::{
get_external_generation_job_status, get_external_generation_queue_overview,
},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/runtime/external-generation/queue-overview",
get(get_external_generation_queue_overview).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/external-generation/jobs/{job_id}",
get(get_external_generation_job_status).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}

View File

@@ -52,22 +52,22 @@ use shared_contracts::{
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::{
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleAudioAssetRecord, PuzzleBackgroundCompileTaskClaimRecordInput,
ExternalGenerationJobEnqueueRecordInput, PuzzleAgentMessageRecord,
PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBackgroundCompileTaskClaimRecordInput,
PuzzleBackgroundCompileTaskReleaseRecordInput, PuzzleCreatorIntentRecord,
PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, PuzzleFormDraftRecord,
PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput,
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
PuzzleLeaderboardSubmitRecordInput, PuzzleLevelGenerationFailureRecordInput,
PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord,
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput,
PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput,
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
};
use std::convert::Infallible;
@@ -79,6 +79,10 @@ use crate::{
should_skip_asset_operation_billing_for_connectivity,
},
auth::{AuthenticatedAccessToken, RuntimePrincipal},
external_generation_worker::{
PUZZLE_COMPILE_DRAFT_JOB_KIND, PUZZLE_GENERATE_IMAGES_JOB_KIND,
PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND,
},
generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha,
http_error::AppError,
llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL},
@@ -185,6 +189,25 @@ async fn release_claimed_puzzle_background_compile_task(
}
}
pub(crate) fn spawn_release_claimed_puzzle_background_compile_task(
state: PuzzleApiState,
task_id: String,
claim_id: String,
session_id: String,
owner_user_id: String,
) {
tokio::spawn(async move {
release_claimed_puzzle_background_compile_task(
&state,
&task_id,
&claim_id,
&session_id,
&owner_user_id,
)
.await;
});
}
fn has_puzzle_cover_image_src(value: &Option<String>) -> bool {
value
.as_deref()
@@ -215,6 +238,65 @@ fn mark_puzzle_initial_generation_started_snapshot(
session
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ExternalGenerationWriteLeaseGuard {
pub(crate) job_id: Option<String>,
pub(crate) worker_id: Option<String>,
pub(crate) lease_token: Option<String>,
}
impl ExternalGenerationWriteLeaseGuard {
pub(crate) fn inline() -> Self {
Self {
job_id: None,
worker_id: None,
lease_token: None,
}
}
pub(crate) fn from_claimed_job(job_id: String, worker_id: String, lease_token: String) -> Self {
Self {
job_id: Some(job_id),
worker_id: Some(worker_id),
lease_token: Some(lease_token),
}
}
}
#[derive(Debug)]
pub(crate) struct PuzzleExternalGenerationWorkerError {
error: AppError,
should_fail_queue_job: bool,
}
impl PuzzleExternalGenerationWorkerError {
pub(crate) fn with_failure_state_written(error: AppError) -> Self {
Self {
error,
should_fail_queue_job: true,
}
}
pub(crate) fn with_failure_state_pending(error: AppError) -> Self {
Self {
error,
should_fail_queue_job: false,
}
}
pub(crate) fn body_text(&self) -> String {
self.error.body_text()
}
pub(crate) fn into_app_error(self) -> AppError {
self.error
}
pub(crate) fn should_fail_queue_job(&self) -> bool {
self.should_fail_queue_job
}
}
pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String {
format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0)
}
@@ -237,7 +319,7 @@ mod mappers;
use self::mappers::*;
mod draft;
use self::draft::*;
pub(crate) use self::draft::*;
mod tags;
@@ -246,7 +328,7 @@ use self::tags::*;
mod generation;
mod vector_engine;
use self::generation::*;
pub(crate) use self::generation::*;
use self::vector_engine::*;
#[cfg(test)]

View File

@@ -137,6 +137,213 @@ pub(crate) async fn create_seeded_puzzle_session_when_form_save_missing(
Ok(replacement.session_id)
}
fn default_puzzle_image_generation_points_cost() -> u64 {
PUZZLE_IMAGE_GENERATION_POINTS_COST
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PuzzleCompileDraftWorkerPayload {
pub session_id: String,
pub owner_user_id: String,
pub billing_asset_id: String,
pub ai_redraw: bool,
#[serde(default = "default_puzzle_image_generation_points_cost")]
pub billing_points_cost: u64,
#[serde(default)]
pub prompt_text: Option<String>,
#[serde(default)]
pub reference_image_src: Option<String>,
#[serde(default)]
pub image_model: Option<String>,
pub requested_at_micros: i64,
#[serde(default)]
pub background_task_id: Option<String>,
#[serde(default)]
pub background_claim_id: Option<String>,
}
pub(crate) async fn execute_puzzle_compile_draft_worker_job(
state: &PuzzleApiState,
request_context: &RequestContext,
payload: PuzzleCompileDraftWorkerPayload,
external_generation_guard: ExternalGenerationWriteLeaseGuard,
) -> Result<PuzzleAgentSessionRecord, PuzzleExternalGenerationWorkerError> {
let now = current_utc_micros();
let session = if payload.ai_redraw {
execute_billable_asset_operation_with_cost(
state.root_state(),
&payload.owner_user_id,
"puzzle_initial_image",
&payload.billing_asset_id,
payload.billing_points_cost,
async {
compile_puzzle_draft_with_initial_cover(
state,
request_context,
payload.session_id.clone(),
payload.owner_user_id.clone(),
payload.prompt_text.as_deref(),
payload.reference_image_src.as_deref(),
payload.image_model.as_deref(),
now,
&external_generation_guard,
)
.await
},
)
.await
} else {
compile_puzzle_draft_with_uploaded_cover(
state,
request_context,
payload.session_id.clone(),
payload.owner_user_id.clone(),
payload.prompt_text.as_deref(),
payload.reference_image_src.as_deref(),
now,
&external_generation_guard,
)
.await
};
match session {
Ok(session) => {
if session
.draft
.as_ref()
.is_some_and(|draft| draft.generation_status == "ready")
{
send_generation_result_subscribe_message_after_completion(
state.root_state(),
GenerationResultSubscribeMessage {
owner_user_id: payload.owner_user_id.clone(),
task_name: Some("拼图".to_string()),
work_name: session.draft.as_ref().map(|draft| draft.work_title.clone()),
status: GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points: if payload.ai_redraw {
payload.billing_points_cost
} else {
0
},
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
}
release_inline_puzzle_compile_background_claim(
state,
&payload,
&external_generation_guard,
);
Ok(session)
}
Err(error) => {
match mark_puzzle_compile_failure_for_worker(
state,
&payload.session_id,
&payload.owner_user_id,
error.body_text(),
now,
&external_generation_guard,
)
.await
{
Ok(()) => {
send_generation_result_subscribe_message_after_completion(
state.root_state(),
GenerationResultSubscribeMessage {
owner_user_id: payload.owner_user_id.clone(),
task_name: Some("拼图".to_string()),
work_name: None,
status: GenerationResultSubscribeMessageStatus::Failed,
consumed_points: 0,
completed_at_micros: now,
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
release_inline_puzzle_compile_background_claim(
state,
&payload,
&external_generation_guard,
);
Err(PuzzleExternalGenerationWorkerError::with_failure_state_written(error))
}
Err(mark_error) => {
Err(PuzzleExternalGenerationWorkerError::with_failure_state_pending(mark_error))
}
}
}
}
}
fn release_inline_puzzle_compile_background_claim(
state: &PuzzleApiState,
payload: &PuzzleCompileDraftWorkerPayload,
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
) {
if external_generation_guard.job_id.is_some() {
return;
}
release_puzzle_compile_background_claim(state, payload);
}
pub(crate) fn release_puzzle_compile_background_claim(
state: &PuzzleApiState,
payload: &PuzzleCompileDraftWorkerPayload,
) {
let (Some(task_id), Some(claim_id)) = (
payload.background_task_id.as_ref(),
payload.background_claim_id.as_ref(),
) else {
return;
};
spawn_release_claimed_puzzle_background_compile_task(
state.clone(),
task_id.clone(),
claim_id.clone(),
payload.session_id.clone(),
payload.owner_user_id.clone(),
);
}
pub(crate) async fn mark_puzzle_compile_failure_for_worker(
state: &PuzzleApiState,
session_id: &str,
owner_user_id: &str,
error_message: String,
failed_at_micros: i64,
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
) -> Result<(), AppError> {
let result = state
.spacetime_client()
.mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput {
session_id: session_id.to_string(),
owner_user_id: owner_user_id.to_string(),
error_message,
failed_at_micros,
external_generation_job_id: external_generation_guard.job_id.clone(),
external_generation_worker_id: external_generation_guard.worker_id.clone(),
external_generation_lease_token: external_generation_guard.lease_token.clone(),
})
.await;
if let Err(error) = result {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id,
owner_user_id,
message = %error,
"拼图 worker 草稿失败态回写失败"
);
return Err(map_puzzle_client_error(error));
}
Ok(())
}
pub(crate) fn select_puzzle_level_for_api(
draft: &PuzzleResultDraftRecord,
level_id: Option<&str>,
@@ -1163,6 +1370,44 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>(
.or_else(|| levels.first())
}
pub(crate) async fn compile_puzzle_draft_with_initial_cover(
state: &PuzzleApiState,
request_context: &RequestContext,
session_id: String,
owner_user_id: String,
prompt_text: Option<&str>,
reference_image_src: Option<&str>,
image_model: Option<&str>,
now: i64,
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
) -> Result<PuzzleAgentSessionRecord, AppError> {
let compiled_session = state
.spacetime_client()
.compile_puzzle_agent_draft_with_external_generation_guard(
session_id,
owner_user_id.clone(),
now,
external_generation_guard.job_id.clone(),
external_generation_guard.worker_id.clone(),
external_generation_guard.lease_token.clone(),
)
.await
.map_err(map_puzzle_compile_error)?;
generate_puzzle_initial_cover_from_compiled_session(
state,
request_context,
compiled_session,
owner_user_id,
prompt_text,
reference_image_src,
image_model,
now,
external_generation_guard,
)
.await
}
pub(crate) async fn generate_puzzle_initial_cover_from_compiled_session(
state: &PuzzleApiState,
request_context: &RequestContext,
@@ -1172,6 +1417,7 @@ pub(crate) async fn generate_puzzle_initial_cover_from_compiled_session(
reference_image_src: Option<&str>,
image_model: Option<&str>,
now: i64,
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
) -> Result<PuzzleAgentSessionRecord, AppError> {
let draft = compiled_session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
@@ -1322,6 +1568,9 @@ pub(crate) async fn generate_puzzle_initial_cover_from_compiled_session(
levels_json: levels_json_with_generated_name.clone(),
candidates_json,
saved_at_micros: current_utc_micros(),
external_generation_job_id: external_generation_guard.job_id.clone(),
external_generation_worker_id: external_generation_guard.worker_id.clone(),
external_generation_lease_token: external_generation_guard.lease_token.clone(),
})
.await
.map_err(map_puzzle_client_error)
@@ -1435,6 +1684,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
prompt_text: Option<&str>,
reference_image_src: Option<&str>,
now: i64,
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
) -> Result<PuzzleAgentSessionRecord, AppError> {
let uploaded_image_src = reference_image_src
.map(str::trim)
@@ -1469,7 +1719,14 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
})?;
let compiled_session = state
.spacetime_client()
.compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now)
.compile_puzzle_agent_draft_with_external_generation_guard(
session_id.clone(),
owner_user_id.clone(),
now,
external_generation_guard.job_id.clone(),
external_generation_guard.worker_id.clone(),
external_generation_guard.lease_token.clone(),
)
.await
.map_err(map_puzzle_compile_error)?;
let draft = compiled_session.draft.clone().ok_or_else(|| {
@@ -1618,6 +1875,9 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
levels_json: levels_json_with_generated_name.clone(),
candidates_json,
saved_at_micros: current_utc_micros(),
external_generation_job_id: external_generation_guard.job_id.clone(),
external_generation_worker_id: external_generation_guard.worker_id.clone(),
external_generation_lease_token: external_generation_guard.lease_token.clone(),
})
.await
.map_err(map_puzzle_client_error)

View File

@@ -22,6 +22,510 @@ pub(crate) fn should_use_uploaded_puzzle_image_directly(
.is_some_and(|value| !value.is_empty())
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PuzzleGenerateImagesWorkerPayload {
pub session_id: String,
pub owner_user_id: String,
pub billing_asset_id: String,
#[serde(default)]
pub level_id: Option<String>,
#[serde(default)]
pub prompt_text: Option<String>,
#[serde(default)]
pub reference_image_src: Option<String>,
#[serde(default)]
pub reference_image_srcs: Vec<String>,
#[serde(default)]
pub reference_image_asset_object_id: Option<String>,
#[serde(default)]
pub reference_image_asset_object_ids: Vec<String>,
#[serde(default)]
pub image_model: Option<String>,
#[serde(default)]
pub ai_redraw: Option<bool>,
#[serde(default)]
pub should_auto_name_level: Option<bool>,
#[serde(default)]
pub work_title: Option<String>,
#[serde(default)]
pub work_description: Option<String>,
#[serde(default)]
pub picture_description: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub theme_tags: Option<Vec<String>>,
#[serde(default)]
pub levels_json: Option<String>,
pub requested_at_micros: i64,
}
impl PuzzleGenerateImagesWorkerPayload {
fn to_action_request(&self) -> ExecutePuzzleAgentActionRequest {
ExecutePuzzleAgentActionRequest {
action: "generate_puzzle_images".to_string(),
prompt_text: self.prompt_text.clone(),
reference_image_src: self.reference_image_src.clone(),
reference_image_srcs: self.reference_image_srcs.clone(),
reference_image_asset_object_id: self.reference_image_asset_object_id.clone(),
reference_image_asset_object_ids: self.reference_image_asset_object_ids.clone(),
image_model: self.image_model.clone(),
ai_redraw: self.ai_redraw,
candidate_count: Some(1),
should_auto_name_level: self.should_auto_name_level,
candidate_id: None,
level_id: self.level_id.clone(),
work_title: self.work_title.clone(),
work_description: self.work_description.clone(),
picture_description: self.picture_description.clone(),
level_name: None,
summary: self.summary.clone(),
theme_tags: self.theme_tags.clone(),
levels_json: self.levels_json.clone(),
}
}
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PuzzleGenerateUiBackgroundWorkerPayload {
pub session_id: String,
pub owner_user_id: String,
pub billing_asset_id: String,
#[serde(default)]
pub level_id: Option<String>,
#[serde(default)]
pub prompt_text: Option<String>,
#[serde(default)]
pub levels_json: Option<String>,
pub requested_at_micros: i64,
}
impl PuzzleGenerateUiBackgroundWorkerPayload {
fn to_action_request(&self) -> ExecutePuzzleAgentActionRequest {
ExecutePuzzleAgentActionRequest {
action: "generate_puzzle_ui_background".to_string(),
prompt_text: self.prompt_text.clone(),
reference_image_src: None,
reference_image_srcs: Vec::new(),
reference_image_asset_object_id: None,
reference_image_asset_object_ids: Vec::new(),
image_model: None,
ai_redraw: None,
candidate_count: None,
should_auto_name_level: None,
candidate_id: None,
level_id: self.level_id.clone(),
work_title: None,
work_description: None,
picture_description: None,
level_name: None,
summary: None,
theme_tags: None,
levels_json: self.levels_json.clone(),
}
}
}
pub(crate) async fn execute_puzzle_generate_images_worker_job(
state: &PuzzleApiState,
request_context: &RequestContext,
payload: PuzzleGenerateImagesWorkerPayload,
external_generation_guard: ExternalGenerationWriteLeaseGuard,
) -> Result<PuzzleAgentSessionRecord, PuzzleExternalGenerationWorkerError> {
let now = current_utc_micros();
let session = execute_billable_asset_operation_with_cost(
state.root_state(),
&payload.owner_user_id,
"puzzle_generated_image",
&payload.billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async {
execute_puzzle_generate_images_worker_job_inner(
state,
request_context,
&payload,
now,
&external_generation_guard,
)
.await
},
)
.await;
match session {
Ok(session) => Ok(session),
Err(error) => {
match mark_puzzle_level_generation_failure_for_worker(
state,
&payload,
error.body_text(),
now,
&external_generation_guard,
)
.await
{
Ok(()) => {
Err(PuzzleExternalGenerationWorkerError::with_failure_state_written(error))
}
Err(mark_error) => {
Err(PuzzleExternalGenerationWorkerError::with_failure_state_pending(mark_error))
}
}
}
}
}
pub(crate) async fn execute_puzzle_generate_ui_background_worker_job(
state: &PuzzleApiState,
request_context: &RequestContext,
payload: PuzzleGenerateUiBackgroundWorkerPayload,
external_generation_guard: ExternalGenerationWriteLeaseGuard,
) -> Result<PuzzleAgentSessionRecord, PuzzleExternalGenerationWorkerError> {
let now = current_utc_micros();
let session = execute_billable_asset_operation_with_cost(
state.root_state(),
&payload.owner_user_id,
"puzzle_ui_background_image",
&payload.billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async {
execute_puzzle_generate_ui_background_worker_job_inner(
state,
request_context,
&payload,
now,
&external_generation_guard,
)
.await
},
)
.await;
match session {
Ok(session) => Ok(session),
Err(error) => {
match mark_puzzle_level_generation_failure_for_external_generation(
state,
&payload.session_id,
&payload.owner_user_id,
payload.level_id.clone(),
payload.levels_json.clone(),
error.body_text(),
now,
&external_generation_guard,
)
.await
{
Ok(()) => {
Err(PuzzleExternalGenerationWorkerError::with_failure_state_written(error))
}
Err(mark_error) => {
Err(PuzzleExternalGenerationWorkerError::with_failure_state_pending(mark_error))
}
}
}
}
}
async fn execute_puzzle_generate_images_worker_job_inner(
state: &PuzzleApiState,
request_context: &RequestContext,
payload: &PuzzleGenerateImagesWorkerPayload,
now: i64,
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
) -> Result<PuzzleAgentSessionRecord, AppError> {
let action_payload = payload.to_action_request();
let target_level_id = payload.level_id.clone();
let levels_json = payload.levels_json.clone();
let session = get_puzzle_session_for_image_generation(
state,
payload.session_id.clone(),
payload.owner_user_id.clone(),
&action_payload,
levels_json.as_deref(),
now,
)
.await?;
let mut draft = session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图结果页草稿尚未生成",
}))
})?;
if let Some(levels_json) = levels_json.as_ref() {
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
}
let mut target_level = select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
let prompt = resolve_puzzle_level_image_prompt(
payload.prompt_text.as_deref(),
&target_level.picture_description,
&draft.summary,
);
let should_auto_name_level = payload
.should_auto_name_level
.unwrap_or_else(|| target_level.level_name.trim().is_empty());
if should_auto_name_level {
let naming =
generate_puzzle_first_level_name(state, target_level.picture_description.as_str())
.await;
target_level.level_name = naming.level_name.clone();
target_level.ui_background_prompt = naming.ui_background_prompt.clone();
}
let reference_image_sources = collect_puzzle_reference_image_sources(
payload.reference_image_src.as_deref(),
payload.reference_image_srcs.as_slice(),
payload.reference_image_asset_object_id.as_deref(),
payload.reference_image_asset_object_ids.as_slice(),
);
let primary_reference_image_src = reference_image_sources.first().map(String::as_str);
// 中文注释:拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
let candidate_start_index = target_level.candidates.len();
let ai_redraw = payload.ai_redraw.unwrap_or(true);
let mut candidates =
if should_use_uploaded_puzzle_image_directly(primary_reference_image_src, ai_redraw) {
vec![
create_uploaded_puzzle_image_candidate(
state,
payload.owner_user_id.as_str(),
&session.session_id,
&target_level.level_name,
&prompt,
primary_reference_image_src.expect("checked reference image"),
candidate_start_index,
)
.await?,
]
} else {
let (_, profile_id) = build_stable_puzzle_work_ids(&session.session_id);
generate_puzzle_image_candidates(
state,
payload.owner_user_id.as_str(),
Some(profile_id.as_str()),
&session.session_id,
&target_level.level_name,
&prompt,
primary_reference_image_src,
ai_redraw,
payload.image_model.as_deref(),
1,
candidate_start_index,
)
.await
.map_err(map_puzzle_generation_endpoint_error)?
};
if candidates.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图候选图生成结果为空",
})),
);
}
if let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
state,
target_level.picture_description.as_str(),
&candidates[0].downloaded_image,
)
.await
.filter(|_| should_auto_name_level)
{
target_level.level_name = refined_naming.level_name.clone();
if refined_naming.ui_background_prompt.is_some() {
target_level.ui_background_prompt = refined_naming.ui_background_prompt.clone();
}
}
let mut updated_levels =
build_puzzle_levels_with_primary_update(&draft, &target_level, primary_reference_image_src);
for candidate in &mut candidates {
candidate.record.prompt = prompt.clone();
}
let selected_candidate = candidates
.iter()
.find(|candidate| candidate.record.selected)
.or_else(|| candidates.first())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图候选图生成结果为空",
}))
})?;
let asset_bundle = generate_puzzle_level_asset_bundle_required(
state,
request_context,
payload.owner_user_id.as_str(),
&session.session_id,
&target_level,
&selected_candidate.downloaded_image,
)
.await?;
attach_puzzle_level_asset_bundle(
&mut updated_levels,
target_level.level_id.as_str(),
asset_bundle,
);
attach_selected_puzzle_candidate_to_levels(
&mut updated_levels,
target_level.level_id.as_str(),
&selected_candidate.record,
);
let levels_json_with_generated_name =
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
let candidates_json = serde_json::to_string(
&candidates
.iter()
.map(|candidate| to_puzzle_generated_image_candidate(&candidate.record))
.collect::<Vec<_>>(),
)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图候选图序列化失败:{error}"),
}))
})?;
state
.spacetime_client()
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
session_id: session.session_id.clone(),
owner_user_id: payload.owner_user_id.clone(),
level_id: Some(target_level.level_id.clone()),
levels_json: levels_json_with_generated_name,
candidates_json,
saved_at_micros: now,
external_generation_job_id: external_generation_guard.job_id.clone(),
external_generation_worker_id: external_generation_guard.worker_id.clone(),
external_generation_lease_token: external_generation_guard.lease_token.clone(),
})
.await
.map_err(map_puzzle_client_error)
}
async fn execute_puzzle_generate_ui_background_worker_job_inner(
state: &PuzzleApiState,
request_context: &RequestContext,
payload: &PuzzleGenerateUiBackgroundWorkerPayload,
now: i64,
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
) -> Result<PuzzleAgentSessionRecord, AppError> {
let action_payload = payload.to_action_request();
let target_level_id = payload.level_id.clone();
let levels_json = payload.levels_json.clone();
let session = get_puzzle_session_for_image_generation(
state,
payload.session_id.clone(),
payload.owner_user_id.clone(),
&action_payload,
levels_json.as_deref(),
now,
)
.await?;
let mut draft = session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图结果页草稿尚未生成",
}))
})?;
if let Some(levels_json) = levels_json.as_ref() {
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
}
let target_level = select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
let raw_prompt = payload
.prompt_text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_default()
.to_string();
let resolved_prompt =
normalize_puzzle_ui_background_prompt(raw_prompt.as_str(), &draft, &target_level);
let generated = generate_puzzle_ui_background_image(
state,
request_context,
payload.owner_user_id.as_str(),
&session.session_id,
&target_level.level_name,
resolved_prompt.as_str(),
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
state
.spacetime_client()
.save_puzzle_ui_background(PuzzleUiBackgroundSaveRecordInput {
session_id: session.session_id.clone(),
owner_user_id: payload.owner_user_id.clone(),
level_id: Some(target_level.level_id.clone()),
levels_json,
prompt: resolved_prompt.clone(),
image_src: generated.image_src.clone(),
image_object_key: Some(generated.object_key.clone()),
saved_at_micros: now,
external_generation_job_id: external_generation_guard.job_id.clone(),
external_generation_worker_id: external_generation_guard.worker_id.clone(),
external_generation_lease_token: external_generation_guard.lease_token.clone(),
})
.await
.map_err(map_puzzle_client_error)
}
pub(crate) async fn mark_puzzle_level_generation_failure_for_worker(
state: &PuzzleApiState,
payload: &PuzzleGenerateImagesWorkerPayload,
error_message: String,
failed_at_micros: i64,
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
) -> Result<(), AppError> {
mark_puzzle_level_generation_failure_for_external_generation(
state,
&payload.session_id,
&payload.owner_user_id,
payload.level_id.clone(),
payload.levels_json.clone(),
error_message,
failed_at_micros,
external_generation_guard,
)
.await
}
async fn mark_puzzle_level_generation_failure_for_external_generation(
state: &PuzzleApiState,
session_id: &str,
owner_user_id: &str,
level_id: Option<String>,
levels_json: Option<String>,
error_message: String,
failed_at_micros: i64,
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
) -> Result<(), AppError> {
let result = state
.spacetime_client()
.mark_puzzle_level_generation_failed(PuzzleLevelGenerationFailureRecordInput {
session_id: session_id.to_string(),
owner_user_id: owner_user_id.to_string(),
level_id,
levels_json,
error_message,
failed_at_micros,
external_generation_job_id: external_generation_guard.job_id.clone(),
external_generation_worker_id: external_generation_guard.worker_id.clone(),
external_generation_lease_token: external_generation_guard.lease_token.clone(),
})
.await;
if let Err(error) = result {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session_id,
owner_user_id = %owner_user_id,
message = %error,
"拼图 worker 关卡生图失败态回写失败"
);
return Err(map_puzzle_client_error(error));
}
Ok(())
}
pub(crate) async fn create_uploaded_puzzle_image_candidate(
state: &PuzzleApiState,
owner_user_id: &str,

File diff suppressed because it is too large Load Diff

View File

@@ -535,6 +535,108 @@ fn puzzle_image_generation_fallback_session_ready_when_asset_pack_complete() {
assert_eq!(session.stage, "ready_to_publish");
}
#[test]
fn puzzle_generate_images_worker_payload_keeps_action_snapshot() {
let raw_levels_json = serde_json::to_string(&vec![json!({
"levelId": "puzzle-level-2",
"levelName": "",
"pictureDescription": "新关卡里有一座发光钟楼。",
"candidates": [],
"selectedCandidateId": null,
"coverImageSrc": null,
"coverAssetId": null,
"generationStatus": "generating",
})])
.expect("levels json");
let levels_json = normalize_puzzle_levels_json_for_module(Some(raw_levels_json.as_str()))
.expect("levels should normalize")
.expect("levels json should exist");
let payload = PuzzleGenerateImagesWorkerPayload {
session_id: "puzzle-session-1".to_string(),
owner_user_id: "user-1".to_string(),
billing_asset_id: "puzzle-session-1:123".to_string(),
level_id: Some("puzzle-level-2".to_string()),
prompt_text: Some("发光钟楼".to_string()),
reference_image_src: None,
reference_image_srcs: vec!["data:image/png;base64,abc".to_string()],
reference_image_asset_object_id: Some("asset-object-1".to_string()),
reference_image_asset_object_ids: vec!["asset-object-2".to_string()],
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
ai_redraw: Some(true),
should_auto_name_level: Some(true),
work_title: Some("暖灯猫街作品".to_string()),
work_description: Some("一套雨夜猫街主题拼图。".to_string()),
picture_description: None,
summary: Some("一套雨夜猫街主题拼图。".to_string()),
theme_tags: Some(vec!["猫咪".to_string(), "雨夜".to_string()]),
levels_json: Some(levels_json.clone()),
requested_at_micros: 123,
};
let encoded = serde_json::to_string(&payload).expect("payload should serialize");
let decoded: PuzzleGenerateImagesWorkerPayload =
serde_json::from_str(encoded.as_str()).expect("payload should deserialize");
assert_eq!(decoded.level_id.as_deref(), Some("puzzle-level-2"));
assert_eq!(decoded.reference_image_srcs.len(), 1);
assert_eq!(
decoded.reference_image_asset_object_ids,
vec!["asset-object-2".to_string()]
);
assert_eq!(decoded.should_auto_name_level, Some(true));
let records = parse_puzzle_level_records_from_module_json(
decoded.levels_json.as_deref().expect("levels json"),
)
.expect("levels should parse as module json");
assert_eq!(records[0].level_id, "puzzle-level-2");
assert_eq!(records[0].generation_status, "generating");
}
#[test]
fn puzzle_generate_ui_background_worker_payload_keeps_action_snapshot() {
let raw_levels_json = serde_json::to_string(&vec![json!({
"levelId": "puzzle-level-3",
"levelName": "钟楼回廊",
"pictureDescription": "新关卡里有一座发光钟楼。",
"uiBackgroundPrompt": "发光钟楼延展成竖屏回廊,远处有暖色窗光。",
"candidates": [],
"selectedCandidateId": null,
"coverImageSrc": null,
"coverAssetId": null,
"generationStatus": "generating",
})])
.expect("levels json");
let levels_json = normalize_puzzle_levels_json_for_module(Some(raw_levels_json.as_str()))
.expect("levels should normalize")
.expect("levels json should exist");
let payload = PuzzleGenerateUiBackgroundWorkerPayload {
session_id: "puzzle-session-1".to_string(),
owner_user_id: "user-1".to_string(),
billing_asset_id: "puzzle-session-1:456".to_string(),
level_id: Some("puzzle-level-3".to_string()),
prompt_text: Some("发光钟楼延展成竖屏回廊".to_string()),
levels_json: Some(levels_json.clone()),
requested_at_micros: 456,
};
let encoded = serde_json::to_string(&payload).expect("payload should serialize");
let decoded: PuzzleGenerateUiBackgroundWorkerPayload =
serde_json::from_str(encoded.as_str()).expect("payload should deserialize");
assert_eq!(decoded.level_id.as_deref(), Some("puzzle-level-3"));
assert_eq!(
decoded.prompt_text.as_deref(),
Some("发光钟楼延展成竖屏回廊")
);
assert_eq!(decoded.requested_at_micros, 456);
let records = parse_puzzle_level_records_from_module_json(
decoded.levels_json.as_deref().expect("levels json"),
)
.expect("levels should parse as module json");
assert_eq!(records[0].level_id, "puzzle-level-3");
assert_eq!(records[0].generation_status, "generating");
}
#[test]
fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() {
assert_eq!(

View File

@@ -11,7 +11,11 @@ use module_assets::{
generate_asset_binding_id, generate_asset_object_id,
};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_contracts::external_generation::{
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
};
use shared_contracts::puzzle_clear::{
PuzzleClearActionRequest, PuzzleClearActionType, PuzzleClearCardAsset,
PuzzleClearDraftResponse, PuzzleClearGenerationStatus, PuzzleClearImageAsset,
@@ -22,7 +26,9 @@ use shared_contracts::puzzle_clear::{
PuzzleClearWorkspaceCreateRequest,
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError;
use spacetime_client::{
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, SpacetimeClientError,
};
use std::{
collections::BTreeMap,
time::{SystemTime, UNIX_EPOCH},
@@ -51,6 +57,7 @@ const PUZZLE_CLEAR_CREATION_PROVIDER: &str = "puzzle-clear-creation";
const PUZZLE_CLEAR_RUNTIME_PROVIDER: &str = "puzzle-clear-runtime";
const PUZZLE_CLEAR_TEMPLATE_ID: &str = "puzzle-clear";
const PUZZLE_CLEAR_TEMPLATE_NAME: &str = "拼消消";
pub(crate) const PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_clear_compile_draft";
const PUZZLE_CLEAR_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/puzzle-clear/runs";
const PUZZLE_CLEAR_ATLAS_CELL_SIZE: u32 = 256;
const PUZZLE_CLEAR_SHEET_COLUMNS: u32 = 4;
@@ -76,6 +83,15 @@ const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD: f32 = 145.0;
const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX: f32 = 36.0;
const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "文字、Logo、水印、按钮、UI 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、单格内部拼接线、内部竖切、内部横切、照片拼贴、相册拼贴、多场景拼贴、双联图、三联图、画中画、单格双图、单格多图、低清晰度、纯色背景、空白背景、白底商品图、孤立主体、单体素材、素材表、图标、贴纸、同品种重复、同一物体多角度、重复同款小图、主体跨格、主体贴边、拼贴、重影、不同图案互相穿插";
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PuzzleClearCompileDraftWorkerPayload {
pub session_id: String,
pub owner_user_id: String,
pub author_display_name: String,
pub payload: PuzzleClearActionRequest,
}
pub async fn create_puzzle_clear_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -160,6 +176,39 @@ pub async fn execute_puzzle_clear_action(
.unwrap_or("拼消消玩家")
.to_string();
let mut payload = payload;
let should_queue_generation = matches!(
payload.action_type,
PuzzleClearActionType::CompileDraft | PuzzleClearActionType::RegenerateAtlas
) && !state.config.external_generation_mode.is_inline();
if should_queue_generation {
let mut queued_response = state
.spacetime_client()
.mark_puzzle_clear_generation_queued(
session_id.clone(),
owner_user_id.clone(),
author_display_name.clone(),
payload.clone(),
)
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
let queue_job = enqueue_puzzle_clear_compile_draft_job(
&state,
&request_context,
&session_id,
owner_user_id.as_str(),
author_display_name.as_str(),
payload,
)
.await?;
queued_response.queue_state = Some(map_puzzle_clear_queue_job_status(queue_job));
return Ok(json_success_body(Some(&request_context), queued_response));
}
if let Err(response) = maybe_prepare_puzzle_clear_assets_inner(
&state,
&request_context,
@@ -210,6 +259,129 @@ pub async fn execute_puzzle_clear_action(
Ok(json_success_body(Some(&request_context), response))
}
async fn enqueue_puzzle_clear_compile_draft_job(
state: &AppState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
author_display_name: &str,
payload: PuzzleClearActionRequest,
) -> Result<ExternalGenerationJobRecord, Response> {
let job_id = build_prefixed_uuid_id("extgen-");
let now_micros = current_utc_micros();
let request_payload_json = serde_json::to_string(&PuzzleClearCompileDraftWorkerPayload {
session_id: session_id.to_string(),
owner_user_id: owner_user_id.to_string(),
author_display_name: author_display_name.to_string(),
payload,
})
.map_err(|error| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"message": format!("拼消消 worker 任务参数序列化失败:{error}"),
})),
)
})?;
state
.spacetime_client()
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
dedupe_key: format!("puzzle-clear:compile-draft:{session_id}:{job_id}"),
job_id,
job_kind: PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND.to_string(),
owner_user_id: owner_user_id.to_string(),
source_module: "puzzle-clear".to_string(),
source_entity_id: session_id.to_string(),
request_label: "拼消消草稿生成".to_string(),
request_payload_json,
max_attempts: 1,
available_at_micros: now_micros,
created_at_micros: now_micros,
})
.await
.map_err(|error| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
map_puzzle_clear_client_error(error),
)
})
}
fn map_puzzle_clear_queue_job_status(
job: ExternalGenerationJobRecord,
) -> ExternalGenerationJobStatusRecord {
ExternalGenerationJobStatusRecord {
operation_id: job.job_id,
status: ExternalGenerationJobStatus::Queued,
phase_label: job.request_label,
phase_detail: "排队中。".to_string(),
progress: 8,
error: job.last_error_message,
updated_at_micros: job.updated_at_micros,
}
}
pub(crate) async fn execute_puzzle_clear_compile_draft_worker_job(
state: &AppState,
request_context: &RequestContext,
mut worker_payload: PuzzleClearCompileDraftWorkerPayload,
) -> Result<PuzzleClearSessionSnapshotResponse, Response> {
if let Err(response) = maybe_prepare_puzzle_clear_assets_inner(
state,
request_context,
worker_payload.session_id.as_str(),
worker_payload.owner_user_id.as_str(),
&mut worker_payload.payload,
)
.await
{
let (error_message, response) = extract_puzzle_clear_response_error_message(response).await;
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
session_id = worker_payload.session_id,
error = %error_message,
"拼消消 worker 素材生成失败,准备回写 failed 状态"
);
if let Err(writeback_error) = state
.spacetime_client()
.mark_puzzle_clear_generation_failed(
worker_payload.session_id.clone(),
worker_payload.owner_user_id.clone(),
worker_payload.author_display_name.clone(),
worker_payload.payload.clone(),
)
.await
{
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
session_id = worker_payload.session_id,
error = %writeback_error,
"拼消消 worker 失败状态回写失败"
);
}
return Err(response);
}
let response = state
.spacetime_client()
.execute_puzzle_clear_action(
worker_payload.session_id,
worker_payload.owner_user_id,
worker_payload.author_display_name,
worker_payload.payload,
)
.await
.map_err(|error| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
Ok(response.session)
}
pub async fn list_puzzle_clear_works(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,

View File

@@ -14,7 +14,11 @@ use module_assets::{
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_contracts::external_generation::{
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
};
use shared_contracts::wooden_fish::{
WoodenFishActionRequest, WoodenFishAudioAsset, WoodenFishCheckpointRunRequest,
WoodenFishDraftResponse, WoodenFishFinishRunRequest, WoodenFishGalleryDetailResponse,
@@ -24,7 +28,9 @@ use shared_contracts::wooden_fish::{
WoodenFishWorkspaceCreateRequest,
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError;
use spacetime_client::{
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, SpacetimeClientError,
};
use crate::generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
@@ -54,6 +60,8 @@ const WOODEN_FISH_CREATION_PROVIDER: &str = "wooden-fish-creation";
const WOODEN_FISH_RUNTIME_PROVIDER: &str = "wooden-fish-runtime";
const WOODEN_FISH_TEMPLATE_ID: &str = "wooden-fish";
const WOODEN_FISH_TEMPLATE_NAME: &str = "敲木鱼";
pub(crate) const WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND: &str =
"wooden_fish_generate_image_assets";
const DEFAULT_HIT_OBJECT_PROMPT: &str = "默认敲击物图案,圆润木质质感,透明背景";
const DEFAULT_HIT_OBJECT_ASSET_ID: &str = "wooden-fish-default-hit-object";
const DEFAULT_HIT_OBJECT_IMAGE_SRC: &str = "/wooden-fish/default-hit-object.png";
@@ -73,6 +81,15 @@ const DEFAULT_HIT_OBJECT_REFERENCE_BYTES: &[u8] = include_bytes!(concat!(
));
const WOODEN_FISH_AUTHOR_FALLBACK_DISPLAY_NAME: &str = "玩家";
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WoodenFishGenerateImageAssetsWorkerPayload {
pub session_id: String,
pub owner_user_id: String,
pub author_display_name: String,
pub payload: WoodenFishActionRequest,
}
pub async fn create_wooden_fish_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -155,6 +172,40 @@ pub async fn execute_wooden_fish_action(
payload.action_type,
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft
);
let should_queue_generation = matches!(
payload.action_type,
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft
| shared_contracts::wooden_fish::WoodenFishActionType::RegenerateHitObject
) && !state.config.external_generation_mode.is_inline();
if should_queue_generation {
let mut queued_response = state
.spacetime_client()
.mark_wooden_fish_generation_queued(
session_id.clone(),
owner_user_id.clone(),
author_display_name.clone(),
payload.clone(),
)
.await
.map_err(|error| {
wooden_fish_error_response(
&request_context,
WOODEN_FISH_CREATION_PROVIDER,
map_wooden_fish_client_error(error),
)
})?;
let queue_job = enqueue_wooden_fish_generate_image_assets_job(
&state,
&request_context,
&session_id,
owner_user_id.as_str(),
author_display_name.as_str(),
payload,
)
.await?;
queued_response.queue_state = Some(map_wooden_fish_queue_job_status(queue_job));
return Ok(json_success_body(Some(&request_context), queued_response));
}
let generation_points_cost = if is_compile_draft {
resolve_wooden_fish_generation_points_cost(&state).await
} else {
@@ -226,6 +277,70 @@ pub async fn execute_wooden_fish_action(
Ok(json_success_body(Some(&request_context), response))
}
async fn enqueue_wooden_fish_generate_image_assets_job(
state: &AppState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
author_display_name: &str,
payload: WoodenFishActionRequest,
) -> Result<ExternalGenerationJobRecord, Response> {
let job_id = build_prefixed_uuid_id("extgen-");
let now_micros = current_utc_micros();
let request_payload_json = serde_json::to_string(&WoodenFishGenerateImageAssetsWorkerPayload {
session_id: session_id.to_string(),
owner_user_id: owner_user_id.to_string(),
author_display_name: author_display_name.to_string(),
payload,
})
.map_err(|error| {
wooden_fish_error_response(
request_context,
WOODEN_FISH_CREATION_PROVIDER,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"message": format!("敲木鱼 worker 任务参数序列化失败:{error}"),
})),
)
})?;
state
.spacetime_client()
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
dedupe_key: format!("wooden-fish:generate-image-assets:{session_id}:{job_id}"),
job_id,
job_kind: WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND.to_string(),
owner_user_id: owner_user_id.to_string(),
source_module: "wooden-fish".to_string(),
source_entity_id: session_id.to_string(),
request_label: "敲木鱼图片素材生成".to_string(),
request_payload_json,
max_attempts: 1,
available_at_micros: now_micros,
created_at_micros: now_micros,
})
.await
.map_err(|error| {
wooden_fish_error_response(
request_context,
WOODEN_FISH_CREATION_PROVIDER,
map_wooden_fish_client_error(error),
)
})
}
fn map_wooden_fish_queue_job_status(
job: ExternalGenerationJobRecord,
) -> ExternalGenerationJobStatusRecord {
ExternalGenerationJobStatusRecord {
operation_id: job.job_id,
status: ExternalGenerationJobStatus::Queued,
phase_label: job.request_label,
phase_detail: "排队中。".to_string(),
progress: 8,
error: job.last_error_message,
updated_at_micros: job.updated_at_micros,
}
}
pub async fn publish_wooden_fish_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
@@ -635,6 +750,40 @@ async fn execute_wooden_fish_action_with_generated_assets(
})
}
pub(crate) async fn execute_wooden_fish_generate_image_assets_worker_job(
state: &AppState,
request_context: &RequestContext,
mut worker_payload: WoodenFishGenerateImageAssetsWorkerPayload,
) -> Result<WoodenFishSessionSnapshotResponse, Response> {
let result = execute_wooden_fish_action_with_generated_assets(
state,
request_context,
worker_payload.session_id.as_str(),
worker_payload.owner_user_id.as_str(),
worker_payload.author_display_name.as_str(),
&mut worker_payload.payload,
)
.await;
if result.as_ref().err().is_some_and(|response| {
response.status().is_server_error()
&& matches!(
worker_payload.payload.action_type,
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft
)
}) {
mark_wooden_fish_generation_failed(
state,
request_context,
worker_payload.session_id.as_str(),
worker_payload.owner_user_id.as_str(),
worker_payload.author_display_name.as_str(),
)
.await;
}
let response = result?;
Ok(response.session)
}
async fn resolve_wooden_fish_generation_points_cost(state: &AppState) -> u64 {
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state,

View File

@@ -66,6 +66,9 @@ pub struct PuzzleDraftCompileInput {
pub session_id: String,
pub owner_user_id: String,
pub compiled_at_micros: i64,
pub external_generation_job_id: Option<String>,
pub external_generation_worker_id: Option<String>,
pub external_generation_lease_token: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -94,6 +97,23 @@ pub struct PuzzleDraftCompileFailureInput {
pub owner_user_id: String,
pub error_message: String,
pub failed_at_micros: i64,
pub external_generation_job_id: Option<String>,
pub external_generation_worker_id: Option<String>,
pub external_generation_lease_token: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleLevelGenerationFailureInput {
pub session_id: String,
pub owner_user_id: String,
pub level_id: Option<String>,
pub levels_json: Option<String>,
pub error_message: String,
pub failed_at_micros: i64,
pub external_generation_job_id: Option<String>,
pub external_generation_worker_id: Option<String>,
pub external_generation_lease_token: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -105,6 +125,9 @@ pub struct PuzzleGeneratedImagesSaveInput {
pub levels_json: Option<String>,
pub candidates_json: String,
pub saved_at_micros: i64,
pub external_generation_job_id: Option<String>,
pub external_generation_worker_id: Option<String>,
pub external_generation_lease_token: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -118,6 +141,9 @@ pub struct PuzzleUiBackgroundSaveInput {
pub image_src: String,
pub image_object_key: Option<String>,
pub saved_at_micros: i64,
pub external_generation_job_id: Option<String>,
pub external_generation_worker_id: Option<String>,
pub external_generation_lease_token: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]

Some files were not shown because too many files have changed in this diff Show More