Compare commits
29 Commits
codex/plat
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6984af782c | |||
| 9ab87956f8 | |||
| 2dcdd90c37 | |||
| 15a527d7f4 | |||
| 264453a714 | |||
| 767da0164a | |||
| a51e63415f | |||
|
|
bdf99468e7 | ||
| 5a1c1c88dd | |||
| 38babc592d | |||
| 660abff773 | |||
| cd49cb0106 | |||
| b7fd36747d | |||
| 951caac32d | |||
| 3bccfd1a83 | |||
| fe30396544 | |||
| 2251fa2f8e | |||
| 4a6c126366 | |||
| 69815d918a | |||
| f87ae3f915 | |||
|
|
21add3dcbc | ||
|
|
1dd58a3d66 | ||
|
|
d78c11d5b7 | ||
| c5763fdf25 | |||
| 31ad55b0cf | |||
| 4f86c1a75b | |||
| 4bb6d0bd1e | |||
| 853d1db618 | |||
| 8d54ea3374 |
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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. 定玩法边界
|
||||
|
||||
|
||||
@@ -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 不只是生成物。
|
||||
|
||||
## 验证命令示例
|
||||
|
||||
|
||||
29
AGENTS.md
29
AGENTS.md
@@ -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
|
||||
|
||||
@@ -9,12 +9,14 @@ Docker Compose
|
||||
├─ spacetimedb :3101,独立数据卷,供 api-server 连接
|
||||
├─ nginx :80 -> api-server:8082,负责静态站点、/admin/、/api/ 反代、upstream timing log、连接限制
|
||||
├─ api-server :8082,Linux release 构建,连接 compose 内 SpacetimeDB
|
||||
├─ external-generation-worker,独立 worker 进程,消费 external_generation_job 队列
|
||||
├─ otelcol :4317/4318,debug 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: ../..
|
||||
|
||||
8
deploy/env/api-server.env.example
vendored
8
deploy/env/api-server.env.example
vendored
@@ -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
|
||||
|
||||
13
deploy/env/external-generation-controller.env.example
vendored
Normal file
13
deploy/env/external-generation-controller.env.example
vendored
Normal 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
|
||||
11
deploy/env/external-generation-worker.env.example
vendored
Normal file
11
deploy/env/external-generation-worker.env.example
vendored
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
## 维护规则
|
||||
|
||||
- 计划文档只记录可执行阶段、负责人切分、验收门禁和当前状态。
|
||||
- 已经稳定为长期约定的内容,应同步沉淀到 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` 或 `.hermes/shared-memory/`。
|
||||
- 已经稳定为长期约定的内容,应同步沉淀到 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` 或 `docs/project-memory/shared-memory/`。
|
||||
- 若代码事实与计划冲突,以代码和当前融合文档为准,并回写更新本目录。
|
||||
|
||||
@@ -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 完成后更新本文档的状态、验收命令和风险。
|
||||
|
||||
退出条件:
|
||||
|
||||
32
docs/project-memory/README.md
Normal file
32
docs/project-memory/README.md
Normal 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。
|
||||
@@ -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 只做服务器初始化,全程运行在目标部署 agent:development 使用 `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 只做服务器初始化,全程运行在目标部署 agent:development 使用 `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 active,worker 停机时停止 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 token;worker 执行期间调用 `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/` 模板仍是正式线上来源。
|
||||
- 生产 Collector:server-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
|
||||
|
||||
@@ -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 table,worker-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. 建议的测试命令和提交信息。
|
||||
```
|
||||
@@ -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/`。
|
||||
- 如果文档与代码冲突,先确认代码事实,再更新过期文档和共享记忆。
|
||||
@@ -50,4 +50,4 @@
|
||||
## 是否需要更新团队记忆
|
||||
|
||||
- [ ] 不需要
|
||||
- [ ] 需要,建议更新:`.hermes/shared-memory/...`
|
||||
- [ ] 需要,建议更新:`docs/project-memory/shared-memory/...`
|
||||
@@ -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 或 failed;inline 模式下不应产生新的 `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 guard;api-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`。
|
||||
|
||||
## 微信历史孤儿作品不要让新注册账号顶替
|
||||
|
||||
@@ -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 进程再重试。
|
||||
|
||||
职责边界:
|
||||
|
||||
@@ -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`
|
||||
@@ -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` 的发布门槛收紧另作后续切片,不混入本次前端模型收口。
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
204
docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md
Normal file
204
docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md
Normal 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”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 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-*` 请求只创建一个 job,worker 内部负责该动作所需的 provider 调用、素材处理、OSS 持久化、失败态写回和业务成功写回。非外部图片生成动作,例如纯元信息保存、标签编辑、发布、试玩启动、运行态动作、删除和公开 read model 读取,继续 inline 执行。
|
||||
|
||||
每个玩法迁移时必须同时接入业务写回 lease guard:worker 路径带 `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'
|
||||
```
|
||||
@@ -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 窗口缓存
|
||||
|
||||
|
||||
@@ -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` 控制单次 lease,worker 会约每三分之一 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` 个 5xx,200 请求平均 `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 请求日志,入口拿不到上下文时允许为空。常用查询:
|
||||
|
||||
|
||||
@@ -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 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
|
||||
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
|
||||
|
||||
@@ -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/`。
|
||||
|
||||
## 当前文档策略
|
||||
|
||||
|
||||
@@ -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 triple,development/release Linux amd64 使用默认值')
|
||||
string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir')
|
||||
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录')
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/web-view/index",
|
||||
"pages/share-grid/index",
|
||||
"pages/wechat-pay/index",
|
||||
"pages/subscribe-message/index"
|
||||
],
|
||||
|
||||
206
miniprogram/pages/share-grid/index.js
Normal file
206
miniprogram/pages/share-grid/index.js
Normal 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();
|
||||
},
|
||||
});
|
||||
3
miniprogram/pages/share-grid/index.json
Normal file
3
miniprogram/pages/share-grid/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "九宫切图"
|
||||
}
|
||||
62
miniprogram/pages/share-grid/index.shared.js
Normal file
62
miniprogram/pages/share-grid/index.shared.js
Normal 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,
|
||||
};
|
||||
67
miniprogram/pages/share-grid/index.test.js
Normal file
67
miniprogram/pages/share-grid/index.test.js
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
20
miniprogram/pages/share-grid/index.wxml
Normal file
20
miniprogram/pages/share-grid/index.wxml
Normal 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>
|
||||
60
miniprogram/pages/share-grid/index.wxss
Normal file
60
miniprogram/pages/share-grid/index.wxss
Normal 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;
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
});
|
||||
|
||||
188
miniprogram/pages/web-view/index.shared.js
Normal file
188
miniprogram/pages/web-view/index.shared.js
Normal 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,
|
||||
};
|
||||
110
miniprogram/pages/web-view/index.test.js
Normal file
110
miniprogram/pages/web-view/index.test.js
Normal 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: '汪汪声浪',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
29
packages/shared/src/contracts/externalGeneration.ts
Normal file
29
packages/shared/src/contracts/externalGeneration.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 路径',
|
||||
'',
|
||||
'## 扫描结论',
|
||||
|
||||
839
scripts/container-worker-smoke.mjs
Normal file
839
scripts/container-worker-smoke.mjs
Normal 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 table,status 显示最近 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
|
||||
`);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 反序列化失败');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
83
scripts/rag/README.md
Normal 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 工具目录,不作为项目知识库索引源。
|
||||
68
scripts/rag/index-docs.mjs
Normal file
68
scripts/rag/index-docs.mjs
Normal 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}`,
|
||||
);
|
||||
46
scripts/rag/rag-config.json
Normal file
46
scripts/rag/rag-config.json
Normal 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
221
scripts/rag/rag-utils.mjs
Normal 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
195
scripts/rag/search-docs.mjs
Normal 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
56
server-rs/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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` 内记录的请求开始时间。
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
108
server-rs/crates/api-server/src/external_generation.rs
Normal file
108
server-rs/crates/api-server/src/external_generation.rs
Normal 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))
|
||||
}
|
||||
750
server-rs/crates/api-server/src/external_generation_worker.rs
Normal file
750
server-rs/crates/api-server/src/external_generation_worker.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
)),
|
||||
)
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user