Refactor local dev stack scheduler

This commit is contained in:
kdletters
2026-05-15 09:37:08 +08:00
parent 0152f9bd67
commit 7a3b137565
20 changed files with 2393 additions and 2088 deletions
File diff suppressed because it is too large Load Diff
+158 -56
View File
@@ -1,20 +1,40 @@
# 开发工作流
更新时间:`2026-05-15`
> 用途:给本地 Hermes 和开发人员提供统一的开发、测试、提交流程。具体命令以 `package.json`、`server-rs/Cargo.toml`、`AGENTS.md` 和相关 `docs/` 最新文档为准。
## 标准流程
## 标准任务流程
```text
同步代码 -> 读取 AGENTS.md -> 读取 .hermes/shared-memory -> 查当前 docs -> 小步实现 -> 本地验证 -> 更新 docs / .hermes -> 提交
同步代码 读取 AGENTS.md 读取 .hermes/shared-memory → 查找/完善 docs → 制定计划 → 小步实现 本地验证 更新文档/记忆 → 提交
```
当前 `docs/` 已压缩为少量融合文档。复杂任务优先读:
## 建议启动方式
1. `docs/README.md`
2. `docs/【项目基线】当前产品与工程约束-2026-05-15.md`
3. `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
4. `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
5. `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
在项目根目录启动 Hermes
```bash
cd /path/to/Genarrative
hermes
```
在本机当前常见路径为:
```bash
/home/dsk/workspace/Genarrative
```
其他开发者以自己本地实际路径为准,不要把个人绝对路径写入共享文档作为通用规则。
## 开发前检查清单
- [ ] 当前分支是否正确
- [ ] 是否已拉取最新代码
- [ ] 是否阅读 `AGENTS.md`
- [ ] 是否阅读 `.hermes/shared-memory/` 相关文件
- [ ] 是否阅读 `README.md` 中的运行和检查命令
- [ ] 是否阅读 `docs/README.md` 及任务相关分类 README
- [ ] 是否存在足够具体的 PRD / 设计 / 技术文档
- [ ] 是否明确测试、验收和文档更新方式
## 本地运行命令
@@ -24,108 +44,190 @@
npm install
```
完整联调:
完整联调开发环境
```bash
npm run dev
```
该命令会启动:
- SpacetimeDB standalone
- Rust `api-server`
- 主站 Vite
- 后台 Vite
开启自动刷新:
```bash
npm run dev -- --watch
```
watch 模式只由外层调度器自动处理后端侧刷新:`spacetime-module` 改动后重新发布模块但不重启 standalone 宿主,`api-server` 改动后重启 Rust 进程。主站 Vite 与后台 Vite 的源码变化交给 Vite 自身 HMR,避免外层 watcher 监听到依赖缓存或临时文件后循环重启。
非 watch 模式下,`npm run dev` 终端支持输入 `rs spacetime``rs api-server``rs web``rs admin-web``rs all`。其中 `rs spacetime` 只会重新发布 `spacetime-module`,不会重启 standalone 宿主;其他模块仍按进程重启。
单独启动 SpacetimeDB
```bash
npm run dev:spacetime
```
单独启动 Rust API server
```bash
npm run dev:api-server
```
单独启动前端:
```bash
npm run dev:web
```
单独启动 Rust API server
单独启动后台管理前端
```bash
npm run api-server
npm run dev:admin-web
```
后台前端:
`npm run dev:api-server` 会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-<timestamp>.log`。完整联调入口 `npm run dev` 启动的 Rust `api-server` 使用同一套日志规则。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`
查看本地 Rust/SpacetimeDB 日志:
```bash
npm run dev:spacetime:logs
```
后台管理前端:
```bash
npm run admin-web:dev
npm run admin-web:build
npm run admin-web:typecheck
```
SpacetimeDB bindings
SpacetimeDB bindings 生成
```bash
npm run spacetime:generate
```
## 常用检查
## 常用检查命令
- 后端通用用户行为埋点统一通过 `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`
编码检查:
```bash
npm run check:encoding
npm run check:spacetime-schema
npm run check:server-rs-ddd
npm run lint:eslint
npm run typecheck
npm run test
npm run build
npm run check:content
```
综合检查
ESLint
```bash
npm run lint:eslint
```
类型检查:
```bash
npm run typecheck
```
综合 lint
```bash
npm run lint
```
测试:
```bash
npm run test
```
生产构建:
```bash
npm run build
```
内容检查:
```bash
npm run check:data
npm run check:overrides
npm run check:smoke
npm run check:content
```
全量检查:
```bash
npm run check
```
视觉小说门禁
DDD 边界检查
```bash
npm run check:visual-novel-vn11
npm run check:visual-novel-vn12
npm run check:server-rs-ddd
```
## 后端默认验证
## 后端相关默认验证
后端代码修改后按范围选择
后端修改后,按 DDD 文档中的验收命令执行。涉及 API smoke 时
- `cargo test -p <crate> --manifest-path server-rs/Cargo.toml`
- `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
- `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`
- `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`
- `npm run check:server-rs-ddd`
- `npm run api-server` 后请求 `/healthz`
- 使用 `npm run dev:api-server` 重新拉起后端。
- 禁止使用 `npm run api-server:maincloud``npm.cmd run api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径;这些只属于历史残留。
- 检查 `/healthz`
- 执行对应自动测试。
- 涉及 SpacetimeDB 表、reducer、procedure、row shape 或绑定变化时,同步更新 `migration.rs`、表目录和生成绑定。
- SpacetimeDB 已有表新增字段必须放在 Rust 表结构体最后,并设置明确默认值;需要修改字段名时,先询问用户并确认迁移计划,再同步更新 `server-rs/crates/spacetime-module/src/migration.rs`、表目录和生成绑定。
- 修改 SpacetimeDB schema 后运行 `npm run check:spacetime-schema`,用自动检查拦截缺 default、插入中间、字段删除/改名/重排/改类型,以及漏改迁移、表目录或绑定。
涉及 SpacetimeDB table、reducer、procedure、row shape 或 bindings 时,还必须运行
关键文档
```bash
npm run spacetime:generate
npm run check:spacetime-schema
```
- `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md`
- `docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md`
- `docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md`
- `docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`
- `docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`
- `docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
- `docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md`
禁止使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
## 前端相关默认验证
## 前端默认验证
前端修改后按范围选择:
前端修改后,应根据修改范围选择:
- `npm run check:encoding`
- `npm run lint:eslint`
- `npm run typecheck`
- `npm run test -- <具体测试文件>`
- `npm run test`
- 页面交互 smoke
- 移动端视口检查
UI 相关修改重点检查
前端原则
- 390px 左右移动端宽度不横向溢出
- 输入法弹出时平台画布不被压缩
- 弹窗、抽屉和独立面板没有实现成当前面板下方展开
- UI 不包含默认规则说明长文
- 私有图片和音频不裸请求 `/generated-*`
- 移动端优先,再兼容网页端
- 页面只展示后端返回的状态,不自行计算结论型业务状态
- 创作中心入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;前端只在 `platformEntryCreationTypes.ts` 做展示派生,api-server 路由熔断也使用同一份配置,禁止恢复前端硬编码入口配置文件
- 优先复用现有面板、抽屉、弹窗,不新建独立大系统
- 不在 UI 中默认写功能说明类文本
- 弹出独立面板的交互不要实现成在当前面板下方追加内容。
## 文档更新
## 文档更新规则
- 工程修改要同步更新当前 `docs/` 文档。
- 新增稳定知识优先合并进现有 4 份文档;只有现文档无法容纳时才新增带 `【标签名】` 的 Markdown
- `.hermes/shared-memory/` 只记录高频、长期、团队共享的摘要和索引。
- 阶段性流水账、一次性计划和已关闭 TODO 不再作为长期仓库文档依据。
- 工程修改要同步更新对应文档。
- 如果没有现文档,新文档统一放入 `docs/` 下合适分类
- `.hermes/shared-memory/` 只记录高频、长期、团队共享的摘要和索引,不替代完整 PRD/技术文档
- 如果 `.hermes/shared-memory/` 与代码或 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正共享记忆。
## 提交前建议让 Hermes 执行
```text
请检查当前 git diff,指出:
1. 是否违反 AGENTS.md 或 .hermes/shared-memory 约定;
2. 是否需要补充 docs
3. 是否有长期知识需要写入 .hermes/shared-memory
4. 建议的测试命令和提交信息。
```
File diff suppressed because it is too large Load Diff
+70 -34
View File
@@ -1,64 +1,100 @@
# 团队协作约定
更新时间:`2026-05-15`
> 用途:约定 3 名开发人员在各自本地 Hermes 中协作开发、共享项目记忆的方式。
## 基本模式
- 每位开发人员在自己的电脑上使用本地 Hermes。
- 团队共享内容放在仓库 `.hermes/``docs/` 中,通过 Git 同步
- 不共享个人 `~/.hermes` 目录、密钥、会话、Token、Cookie、认证文件、本地私密路径或构建产物
- 每位开发人员本地拉取同一个项目仓库,独立修改代码、运行测试、提交分支
- 团队共享内容优先放在本仓库 `.hermes/` `docs/` 中,通过 Git 同步
- 不共享个人 `~/.hermes` 目录。
## 共享与禁止共享
推荐共享:
- `.hermes/shared-memory/` 团队级长期记忆
- `.hermes/plans/` 阶段性实施计划
- `.hermes/todos/` 已确定需要执行、但尚未进入实施的共享 TODO 计划
- `.hermes/skills/` 未来可复用仓库级 skills
- `docs/` 中 PRD、设计、技术、经验、审计、查询手册
- `AGENTS.md` 项目级 Agent 约束
禁止提交:
- 个人 `~/.hermes/config.yaml`
- 个人 `~/.hermes/.env`
- 个人 `~/.hermes/sessions/`
- API Key、Token、Cookie、认证文件
- 个人本地私密路径和个人隐私信息
- 构建产物、日志、缓存、数据库 dump
## 开发前
1. 拉取最新代码。
2. 阅读 `AGENTS.md`
3. 阅读 `.hermes/shared-memory/` 中与任务相关的文件。
4. 阅读 `docs/README.md` 4 份当前融合文档中与任务相关的部分
5. 如果文档不足以指导编码,先补充或修正文档。
当前文档入口:
- `docs/【项目基线】当前产品与工程约束-2026-05-15.md`
- `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
- `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
- `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
4. 阅读 `docs/README.md`任务相关分类 README
5. 阅读对应 PRD、设计、技术、经验或审计文档。
6. 如果文档不足以指导编码,先补充或修正文档。
## 开发中
- 保持修改范围聚焦,不做无关重构。
- 优先复用、修改、扩展现有系统、页面、组件和弹层
- 新增 Markdown 文档时,文件名必须以分类标签开头,格式为 `【标签名】中文标题-YYYY-MM-DD.md`
- 阶段性计划、一次性 TODO 和已关闭实验不要长期沉淀为仓库文档;仍有效内容合并进当前 `docs/``.hermes/shared-memory/`
- 复用、修改、扩展现有系统优先,避免新建重复系统或页面
- 新增 Markdown 文档时,文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;只在任务需要时重命名历史文档,避免无关大 diff
- 涉及中文文本时注意 UTF-8 编码和乱码排查。
- 涉及后端时遵循 DDD 分层,不把业务真相下沉到前端或临时兼容层。
- `maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求均视为历史残留,禁止新增、运行或引用。
- API smoke 统一使用 `npm run api-server` `/healthz`
- 涉及 SpacetimeDB 表结构、发布或迁移时,先看当前后端架构文档的 schema 变更规则和表目录
- 涉及生产发布、服务器配置、Jenkins Job 重建或回滚时,先看当前开发运维文档。
- `maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求均视为历史残留,禁止新增、运行或引用API smoke 统一使用 `npm run dev:api-server``/healthz`
- 涉及 SpacetimeDB 表结构、发布或迁移时,先看 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md` `SPACETIMEDB_TABLE_CATALOG.md`
- 涉及生产发布、服务器配置、Jenkins Job 重建或回滚时,先看 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## 开发后
1. 运行与修改范围匹配的测试或验证命令。
2. 更新相关 `docs/` 当前文档。
3. 若产生长期有效知识,更新 `.hermes/shared-memory/`
4.形成可复用流程,考虑沉淀到 `.hermes/skills/`
5. 提交信息区分代码变更、文档变更和共享记忆变更
2. 更新相关 `docs/` 文档。
3. 新增或沉淀 Markdown 文档时,确认文件名已使用 `【标签名】` 前缀
4.产生长期有效知识,更新 `.hermes/shared-memory/`
5. 若形成可复用流程,考虑沉淀到 `.hermes/skills/`
6. 在提交信息中区分代码变更与文档/记忆变更。
## 文档阅读顺序
通用任务建议:
1. `README.md`
2. `AGENTS.md`
3. `.hermes/shared-memory/`
4. `docs/README.md`
5. `docs/experience/README.md`
6. `docs/audits/README.md`
7. 任务所属分类:`docs/design/``docs/technical/``docs/planning/``docs/prd/``docs/reference/``docs/tracking/``docs/operations/`
后端任务建议:
1. `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md`
2. `docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md`
3. `docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`
4. `docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md`
5. `docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`
6. `docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
7. `docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md`
## 共享记忆更新准则
适合更新:
- 稳定架构约定
- 长期开发流程
- 已验证的踩坑和排障步骤
- 重要接口契约变化
- 团队协作规范变化
- 当前文档索引或阅读顺序变化
- 新增稳定架构约定
- 新增长期开发流程
- 已验证的踩坑和排障步骤
- 重要接口契约变化
- 团队协作规范变化
- 文档索引或阅读顺序变化
不适合更新:
- 一次性临时计划
- 未验证猜测
- 个人偏好和个人路径
- 敏感信息
- 大段聊天记录
- 一次性临时计划
- 未验证猜测
- 个人偏好和个人路径
- 敏感信息
- 大段聊天记录
@@ -146,8 +146,8 @@ metadata:
```bash
# 只看后台页面 + api-server,不要求 SpacetimeDB 真实数据
npm run api-server
npm run admin-web:dev -- --host 127.0.0.1
npm run dev:api-server
npm run dev:admin-web -- --admin-web-host 127.0.0.1
# 完整 Rust 本地栈:SpacetimeDB + 发布模块 + api-server + 主站 + 后台
npm run dev
@@ -155,19 +155,19 @@ npm run dev
验证地址通常为:
- `npm run api-server` 单独启动:api-server `http://127.0.0.1:3100/healthz`,后台前端 `http://127.0.0.1:5173/admin/`
- `npm run dev:api-server` 单独启动:api-server 默认 `http://127.0.0.1:8082/healthz`,后台前端默认 `http://127.0.0.1:3102/admin/`
- `npm run dev` 完整栈:SpacetimeDB `http://127.0.0.1:3101/v1/ping`api-server `http://127.0.0.1:8082/healthz`,主站 `http://127.0.0.1:3000/`,后台 `http://127.0.0.1:3102/admin/`
注意:
- `npm run api-server` 首次启动可能先编译 Rust,后台进程短时间内无完整日志;等待编译完成后再查端口。
- 不要默认用 `3200` 验证 api-server;当前脚本环境变量常见为 `GENARRATIVE_API_PORT=3100`。不确定时 `ss -ltnp | grep api-server` 或读取进程环境核对,敏感值输出必须打码。
- `npm run dev:api-server` 首次启动可能先编译 Rust,后台进程短时间内无完整日志;等待编译完成后再查端口。
- 不要默认用 `3200` 验证 api-server;当前脚本默认 `GENARRATIVE_API_PORT=8082`,但会按端口占用情况漂移。不确定时 `[dev] api-server:` 日志为准,或读取进程环境核对,敏感值输出必须打码。
- `admin-web``/` 可能返回 302 跳转到 `/admin/`;验证前端时直接请求 `/admin/`
- api-server 启动日志中 SpacetimeDB `127.0.0.1:3101` 连接被拒绝,不一定代表 api-server 没起来;只表示依赖的本地 SpacetimeDB 不可用。后台中需要读 SpacetimeDB 的页面(如埋点明细、表查询)要等 SpacetimeDB 可用后才能返回真实数据。
- `npm run dev` 依赖 `spacetime` CLI;先用 `command -v spacetime && spacetime --version` 确认可用。
- 本地和人工排障不再使用 `spacetime --root-dir`。如果看到 `bin/current/spacetimedb-cli` 缺失类错误,优先确认是否仍在运行旧脚本或旧发布包;本地开发应使用 `npm run dev` / `npm run dev:rust`,通过项目脚本和 `--data-dir` 隔离 SpacetimeDB 数据目录,不再把用户级 SpacetimeDB 安装同步到项目目录。
- 本地和人工排障不再使用 `spacetime --root-dir`。如果看到 `bin/current/spacetimedb-cli` 缺失类错误,优先确认是否仍在运行旧脚本或旧发布包;本地开发应使用 `npm run dev` / `npm run dev:spacetime`,通过项目脚本和 `--data-dir` 隔离 SpacetimeDB 数据目录,不再把用户级 SpacetimeDB 安装同步到项目目录。
- `scripts/dev-rust-stack.sh` 默认 `api timeout: 300s`. 合并 master 后首次 Rust 依赖/工作区重编译可能超过 300s,导致完整 `npm run dev` 在 api-server 就绪前超时并回收 SpacetimeDB。先让 Rust 编译完成,或临时用 `bash scripts/dev-rust-stack.sh --skip-spacetime --skip-publish --api-timeout-seconds 900` 预热 api-server 编译;之后再重新跑完整 `npm run dev`
- `scripts/dev.mjs` 默认 `api timeout: 600s`. 合并 master 后首次 Rust 依赖/工作区重编译可能超过默认等待窗口,导致完整 `npm run dev` 在 api-server 就绪前超时并回收 SpacetimeDB。先让 Rust 编译完成,或临时用 `npm run dev:api-server -- --api-timeout-seconds 900` 预热 api-server 编译;之后再重新跑完整 `npm run dev`
- 用户贴出的 Hermes background watch 通知可能来自已退出的旧 session。先用 `process poll` 查该 session 状态,再判断是否需要处理;不要把旧失败误判成当前服务失败。
## 测试与验证
@@ -222,4 +222,4 @@ npm install
- `references/spacetimedb-http-sql-sats-display.md`:通过 HTTP SQL 读取 private table 时,enum / Option / Timestamp 的 SATS 原始 rows 如何转换为后台列表、详情和 Excel 可读值。
- `references/daily-login-tracking-trigger-points.md`:排查后台 `daily_login` 埋点为何不是登录接口写入,而是任务中心读取/领奖兜底写入的触发点记录。
- `references/daily-login-auth-closure.md`:将方案A拆出的每日登录埋点入口接入真实认证成功链路时的推荐接入点、非阻断语义、测试和提交注意事项。
- `references/dev-rust-stack-startup-2026-05-08.md``npm run dev` / `scripts/dev-rust-stack.sh` 在 WSL/Linux 下改用用户级 SpacetimeDB CLI、项目数据目录和显式 publish server,避免项目级 `--root-dir` 与冷编译超时的修复记录。
- `references/dev-rust-stack-startup-2026-05-08.md``npm run dev` / `scripts/dev.mjs` 在 WSL/Linux 下改用用户级 SpacetimeDB CLI、项目数据目录和显式 publish server,避免项目级 `--root-dir` 与冷编译超时的修复记录。
@@ -1,7 +1,7 @@
---
name: genarrative-dev-stack-port-routing
short_description: 修改 Genarrative 本地 dev 启动端口、代理目标、端口冲突处理时使用。
description: 在 Genarrative 中修改 npm run dev / npm run dev:rust / npm run dev:web 的本地启动端口、端口可用性探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标和后台 admin-web 启动串联时使用。
description: 在 Genarrative 中修改 npm run dev / dev:spacetime / dev:api-server / dev:web / dev:admin-web 的本地启动端口、端口可用性探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标和后台 admin-web 启动串联时使用。
version: 1.0.0
author: Hermes Agent
license: MIT
@@ -13,11 +13,11 @@ metadata:
# Genarrative 本地 dev 启动端口与代理目标串联流程
用于维护 Genarrative 本地开发栈启动脚本,重点覆盖 `npm run dev` / `npm run dev:rust` / `npm run dev:web` 的端口检查、端口漂移和后续流程目标传递。
用于维护 Genarrative 本地开发栈启动脚本,重点覆盖 `npm run dev` 与四个 `dev:*` 单模块命令的端口检查、端口漂移和后续流程目标传递。
## 适用场景
- 修改 `scripts/dev-rust-stack.sh``scripts/dev-web-rust.mjs``scripts/dev-stack-port-utils.mjs`
- 修改 `scripts/dev.mjs``scripts/dev-utils.mjs``scripts/dev-stack-port-utils.mjs`
- 处理 `3000``3101``3102``8082` 等端口被占用导致本地开发栈启动失败。
- 排查 Vite 代理仍指向旧 api-server 端口、前端打开了旧 dev server、后台代理错配。
- 调整 SpacetimeDB standalone、publish、Rust `api-server`、主站 Vite、后台 Vite 的启动顺序。
@@ -37,23 +37,21 @@ metadata:
## 实现入口
- `package.json`
- `dev``dev:rust`:执行 `node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh`
- `dev:web`:执行 `node scripts/dev-web-rust.mjs`
- `dev`:执行 `node scripts/dev.mjs`,启动完整四模块
- `dev:spacetime` / `dev:api-server` / `dev:web` / `dev:admin-web`:执行 `node scripts/dev.mjs <module>`
- `scripts/dev-stack-port-utils.mjs`
- `isPortAvailable(...)`:探测端口是否可监听。
- `findAvailablePort(...)`:从优先端口向后寻找可用端口,`0` 表示申请临时端口。
- `resolveDevStackPorts(...)`:一次性解析 SpacetimeDB、api-server、主站 Vite、后台 Vite 端口,并避免本次解析结果互相冲突。
- CLI 模式:`node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:3101 api:127.0.0.1:8082 web:0.0.0.0:3000 adminWeb:127.0.0.1:3102`
- `scripts/dev-rust-stack.sh`
- 解析 CLI 参数后,先计算 `API_TARGET_HOST``ADMIN_WEB_TARGET_HOST`
- 调用 `resolve_dev_stack_ports` 覆盖 `SPACETIME_PORT``API_PORT``WEB_PORT``ADMIN_WEB_PORT`
- 再构造 `SPACETIME_SERVER``RUST_SERVER_TARGET`
- `scripts/dev-web-rust.mjs`
- 单独启动主站前端时,也先用 `findAvailablePort` 检查 `WEB_PORT` / 默认 `3000`
- `scripts/dev.mjs`
- 解析 CLI 参数后统一计算 client host、端口、`SPACETIME_SERVER``RUST_SERVER_TARGET`
- 完整栈按 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 顺序启动
- 单模块命令复用同一套参数和 env 解析
## 必须保持的传递链路
`npm run dev` / `npm run dev:rust` 中端口解析后,必须同步到以下位置:
`npm run dev` 和四个 `dev:*` 单模块命令中端口解析后,必须同步到以下位置:
1. SpacetimeDB 启动:`spacetime start --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}"`
2. SpacetimeDB 发布:`spacetime publish ... --server "${SPACETIME_SERVER}"`
@@ -61,7 +59,7 @@ metadata:
4. api-server 健康检查:`wait_for_api_server "${RUST_SERVER_TARGET}/healthz" ...`
5. 主站 Vite`RUST_SERVER_TARGET``GENARRATIVE_RUNTIME_SERVER_TARGET``ADMIN_WEB_TARGET``ADMIN_WEB_PORT``--port=${WEB_PORT}``--host=${WEB_HOST}`
6. 后台 Vite`ADMIN_API_TARGET``GENARRATIVE_API_TARGET``GENARRATIVE_API_PORT``--port=${ADMIN_WEB_PORT}`
7. 控制台日志:`[dev:ports]``[dev:rust] web/admin web/rust api/spacetime` 必须显示最终实际地址。
7. 控制台日志:`[dev:ports]``[dev] web/admin web/api-server/spacetime` 必须显示最终实际地址。
如果只改了其中一段,通常会出现:浏览器打开的前端可用,但 `/api/*` 代理到旧端口;后台页面可用但后台 API 失败;SpacetimeDB 启动在新端口但 publish 仍发往旧端口。
@@ -69,17 +67,13 @@ metadata:
1. 先读当前脚本和文档:
- `scripts/dev-stack-port-utils.mjs`
- `scripts/dev-rust-stack.sh`
- `scripts/dev-web-rust.mjs`
- `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
- `scripts/dev.mjs`
- `scripts/dev-utils.mjs`
- `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`
- `.hermes/shared-memory/pitfalls.md`
2. 优先改公共端口工具,不要把端口探测逻辑复制到多个脚本。
3. 对 Bash 脚本只做局部补丁,避免整文件重写导致中文注释或换行大面积变化
4. 修改 `dev-rust-stack.sh` 时确认变量顺序:
- 先有 `REPO_ROOT`
- 再计算 `API_TARGET_HOST` / `ADMIN_WEB_TARGET_HOST`
- 再调用端口解析工具。
- 再构造 `SPACETIME_SERVER` / `RUST_SERVER_TARGET`
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。
5. 修改 `dev:web` 时不要自动改后端目标策略;`dev:web` 只负责主站 Vite 端口可用性与已有后端目标选择。
6. 同步更新技术文档和团队共享记忆。
@@ -88,7 +82,7 @@ metadata:
最小验证:
```bash
bash -n scripts/dev-rust-stack.sh
node --check scripts/dev.mjs
npm run test -- scripts/dev-stack-port-utils.test.ts
npm run check:encoding
node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 api:127.0.0.1:0 web:0.0.0.0:0 adminWeb:127.0.0.1:0
@@ -101,8 +95,8 @@ node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 ap
3. 调用 `resolveDevStackPorts`,断言四个结果互不相同。
4. 如果实际启动完整栈,观察控制台:
- `[dev:ports] ... 不可用,改用 ...`
- `[dev:rust] rust api: http://...:<actual-api-port>`
- `[dev:rust] spacetime: http://...:<actual-spacetime-port>`
- `[dev] api-server: http://...:<actual-api-port>`
- `[dev] spacetime: http://...:<actual-spacetime-port>`
- 主站和后台 Vite 启动端口与日志一致。
完整启动属于长驻进程。需要 smoke 时用 background 方式启动,并另开命令检查 `/healthz``/v1/ping` 和页面端口;不要等待 `npm run dev` 自然退出。
@@ -119,9 +113,9 @@ node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 ap
## 验收清单
- [ ] 端口工具有测试覆盖端口被占用和多端口互斥解析。
- [ ] `dev-rust-stack.sh` 通过 `bash -n`
- [ ] `npm run dev` / `npm run dev:rust` 的 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 都使用实际端口。
- [ ] `scripts/dev.mjs` 通过 `node --check`
- [ ] `npm run dev` 的 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 都使用实际端口。
- [ ] `npm run dev:web` 在主站端口不可用时能切换到可用端口。
- [ ] 文档同步更新 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
- [ ] 文档同步更新 `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`
- [ ] 长期踩坑同步更新 `.hermes/shared-memory/pitfalls.md`
- [ ] 修改中文文件后运行 `npm run check:encoding`
+2 -1
View File
@@ -46,7 +46,8 @@ npm run dev
- `npm run dev` 会启动 SpacetimeDB standalone、Rust `api-server`、主站 Vite 与后台 Vite,适合完整联调。
- 主站默认地址是 `http://127.0.0.1:3000`,后台可从 `http://127.0.0.1:3000/admin/` 进入,也可直连 `http://127.0.0.1:3102`
- 如果只想单独启动前端页面,可使用 `npm run dev:web`,默认代理到本地 Rust `api-server`
- 四个模块可独立启动:`npm run dev:spacetime``npm run dev:api-server``npm run dev:web``npm run dev:admin-web`
- 如需自动刷新后端模块,使用 `npm run dev -- --watch`;其中 `spacetime-module` 改动后只会重新发布模块,不会重启 standalone,`api-server` 改动后会重启 Rust 进程。主站和后台前端源码变化交给 Vite 自身 HMR,不由外层 watcher 重启。非 watch 模式下可在 `npm run dev` 终端输入 `rs api-server``rs web``rs admin-web``rs spacetime``rs all`,其中 `rs spacetime` 也是只重新发布模块。
构建生产包:
+49 -13
View File
@@ -1,19 +1,55 @@
# Genarrative 文档入口
# 文档总览
本目录已经在 `2026-05-15` 完成压缩整理:旧 PRD、阶段计划、修复记录、审计报告、技术方案和查询手册中的仍有效内容,统一融合到下列当前文档。旧文档不再作为实现依据
`docs/` 现在按主题拆成了 6 类;旧后端路线文档开始聚合和删除,后续实现以 Rust / SpacetimeDB 当前基线为准
## 当前文档
## 快速入口
- [【项目基线】当前产品与工程约束-2026-05-15.md](./【项目基线】当前产品与工程约束-2026-05-15.md):产品定位、平台入口、UI 约束、协作规则和废弃路线
- [【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md](./【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md)server-rs DDD 边界、API 分组、SpacetimeDB schema 变更规则和当前表目录
- [【玩法创作】平台入口与玩法链路-2026-05-15.md](./【玩法创作】平台入口与玩法链路-2026-05-15.md):创作 Tab、草稿架、拼图、抓大鹅、视觉小说、方洞、大鱼、汪汪声浪和儿童向玩法的当前口径
- [【开发运维】本地开发验证与生产运维-2026-05-15.md](./【开发运维】本地开发验证与生产运维-2026-05-15.md):本地启动、测试、编码检查、SpacetimeDB 变更流程、生产运维、埋点和运营查询
- [经验沉淀](./experience/README.md):项目开发经验、UI 交接、历史实现经验
- [审计与复盘](./audits/README.md):工程审查、文本/乱码审计、专项落地审计
- [系统设计](./design/README.md):玩法、关系、物品与对话设计
- [技术方案](./technical/README.md):动画、服务端、外部产品形态拆解
- [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级。
- [参考目录](./reference/README.md):脚本/Function 速查入口。
重点补充:RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。
- [埋点查询](./tracking/README.md):埋点原始事件与聚合投影的本地 SQL 查询。
- [运营查询](./operations/README.md):任务、领奖、钱包对账等后台核查查询。
- [PRD](./prd/README.md):产品需求与阶段计划;参考 MOKU / 幕间类 AI 文游的陶泥儿 `text-game` 模板口径见 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md),视觉小说模板 TXT 玩法平台化接入口径见 [AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md](./prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md),创意互动内容 Agent Phase 1 的 LangChain-Rust PoC、拼图闭环和并行任务拆分见 [CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md](./prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md),幸存者类模板闭环见 [AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md](./prd/AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md),后台管理独立前端工程见 [ADMIN_WEB_CONSOLE_PRD_2026-04-30.md](./prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md),方洞挑战创作、发布与试玩闭环见 [AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md](./prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md)。
新增稳定知识优先合并到上述文档。只有上述文档无法容纳某个长期主题时,才新增同目录的 `【标签名】中文标题-YYYY-MM-DD.md` 文档
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)
## 维护规则
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
1. 代码和当前文档冲突时,以代码为准,并立即修正文档
2. 新增 Markdown 文件名必须使用 `【标签名】中文标题-YYYY-MM-DD.md`
3.`server-node`、Express、PostgreSQL、Go 服务端、`maincloud`、人工 `spacetime --root-dir`、Match3D 新草稿 Rodin/GLB、拼图和抓大鹅音频生成入口,均不再作为当前实现依据
4. 涉及 SpacetimeDB 表、reducer、procedure、row shape 或 bindings 时,必须同步修改代码、迁移、生成绑定和 [后端架构文档](./【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md) 的表目录。
创作 Agent 问答流式失败时保留已显示回复、并透出更具体上游错误的契约见 [CREATION_AGENT_STREAM_FAILURE_RETENTION_FIX_2026-05-05.md](./technical/CREATION_AGENT_STREAM_FAILURE_RETENTION_FIX_2026-05-05.md)
`maincloud` 相关脚本、环境变量、测试名和文档要求已统一判定为历史残留,后续禁止新增、运行或引用;当前后端 smoke 使用 `npm run dev:api-server``/healthz`,详细规则见 [MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md](./technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md)
基于 LangChain-Rust,以“感知—思考—记忆—行动—反思—协作”闭环完成图文创意理解、拼图模板选择、积分范围确认、拼图草稿字段填充、立即试玩和自然语言修订草稿字段的 Agent 方案见 [CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md](./technical/CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md)。
创意互动内容 Agent Phase 1 的产品边界、实现细节、SpacetimeDB 落点、前端接入和可并行任务拆分见 [CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md](./prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md)。
视觉小说模板接入以 [AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md](./prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md) 为最新口径:只吸收外部 TXT 玩法的模板创作与运行经验,禁止迁入外部平台功能,并删除回放。
AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md) 为最新口径:只吸收 MOKU / 幕间类 AI 文游的剧本游乐场、自由行动、AI GM、记忆和模拟器强反馈经验,禁止迁入外部社区、支付、榜单、私有存档或回放。
## 推荐阅读顺序
1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。
2. 再看 [工程审查总览](./audits/engineering/README.md) 和 [文本审计总览](./audits/text/README.md),了解当前风险。
3. 需要排期时看 [规划与优先级](./planning/README.md)。
4. 需要补方案时进入 [系统设计](./design/README.md) / [技术方案](./technical/README.md);涉及后端先看 [当前后端实现基线](./technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md),涉及生产发布链路先看 [生产部署计划](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),涉及 SpacetimeDB 表结构变更时再看 [表结构变更约束](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
5. 需要对齐目标边界时再进入 [PRD](./prd)。
## 分类规则
- `experience/`:偏方法论、交接经验、长期有效的开发结论。
- `audits/`:偏“现状扫描 / 问题定位 / 是否达标”的审查类文档。
- `design/`:偏玩法机制、叙事关系、系统结构设计。
- `technical/`:偏技术选型、实现路线、竞品/产品形态拆解。
- `planning/`:偏阶段优先级与推进顺序。
- `reference/`:偏目录、速查、检索辅助。
- `tracking/`:偏埋点原始事实和聚合投影查询,不放任务进度或钱包对账。
- `operations/`:偏后台运营核查、对账和排障查询。
## 文档命名规则
后续新增的 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`。标签用于跨目录检索,不替代上面的目录分类;历史文档不要求批量重命名,除非本次任务明确涉及该文档。
+6 -6
View File
@@ -4,16 +4,16 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh",
"dev:rust": "node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh",
"dev:rust:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
"dev:web": "node scripts/dev-web-rust.mjs",
"admin-web:dev": "npm --prefix apps/admin-web run dev --",
"dev": "node scripts/dev.mjs",
"dev:spacetime": "node scripts/dev.mjs spacetime",
"dev:api-server": "node scripts/dev.mjs api-server",
"dev:web": "node scripts/dev.mjs web",
"dev:admin-web": "node scripts/dev.mjs admin-web",
"dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
"admin-web:build": "node scripts/admin-web-build.mjs build",
"admin-web:typecheck": "node scripts/admin-web-build.mjs typecheck",
"admin-web:preview": "npm --prefix apps/admin-web run preview --",
"spacetime:generate": "node scripts/generate-spacetime-bindings.mjs",
"api-server": "node scripts/api-server-dev.mjs",
"check:api-server-env": "node scripts/check-api-server-env.mjs",
"deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",
"build:production-release": "node scripts/run-bash-script.mjs scripts/build-production-release.sh",
-292
View File
@@ -1,292 +0,0 @@
import { execFileSync, spawn } from 'node:child_process';
import {
createWriteStream,
existsSync,
mkdirSync,
readFileSync,
} from 'node:fs';
import { dirname, isAbsolute, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = process.cwd();
const apiServerExePath = resolve(
repoRoot,
'server-rs/target/debug/api-server.exe',
);
const LOCAL_ENV_FILES = ['.env', '.env.local', '.env.secrets.local'];
function buildProtectedEnvKeys(baseEnv) {
return new Set(
Object.entries(baseEnv)
.filter(([, value]) => String(value ?? '').trim())
.map(([key]) => key),
);
}
const shellEnvKeys = buildProtectedEnvKeys(process.env);
function loadEnvFile(path, target, protectedKeys = shellEnvKeys) {
if (!existsSync(path)) {
return;
}
const rawText = readFileSync(path, 'utf8');
for (const rawLine of rawText.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
if (!match) {
continue;
}
const [, key, rawValue] = match;
// 只保留启动命令行和外层 shell 已显式传入的环境变量优先级;
// `.env.local` 与 `.env.secrets.local` 需要能覆盖 `.env`
// 否则本地短信登录或私密模型密钥会被默认空值压住。
if (protectedKeys.has(key)) {
continue;
}
target[key] = rawValue.replace(/^['"]|['"]$/gu, '');
}
}
export function loadApiServerEnv(
repoRootPath,
target,
protectedKeys = shellEnvKeys,
) {
// 保持与 dev-web-rust.mjs / dev-rust-stack.sh 一致:
// shell > .env > .env.local > .env.secrets.local。
for (const fileName of LOCAL_ENV_FILES) {
loadEnvFile(resolve(repoRootPath, fileName), target, protectedKeys);
}
}
export function mergeApiServerEnv(repoRootPath, baseEnv = process.env) {
const mergedEnv = { ...baseEnv };
loadApiServerEnv(repoRootPath, mergedEnv, buildProtectedEnvKeys(baseEnv));
return mergedEnv;
}
export function formatApiServerLogTimestamp(date = new Date()) {
const pad = (value) => String(value).padStart(2, '0');
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
'-',
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join('');
}
export function resolveApiServerLogFile(
repoRootPath,
env = process.env,
now = new Date(),
) {
const explicitLogFile = String(
env.GENARRATIVE_API_SERVER_LOG_FILE ?? '',
).trim();
if (explicitLogFile) {
return isAbsolute(explicitLogFile)
? explicitLogFile
: resolve(repoRootPath, explicitLogFile);
}
const logDir =
String(env.GENARRATIVE_API_SERVER_LOG_DIR ?? '').trim() ||
'logs/api-server';
const resolvedLogDir = isAbsolute(logDir)
? logDir
: resolve(repoRootPath, logDir);
return resolve(
resolvedLogDir,
`api-server-${formatApiServerLogTimestamp(now)}.log`,
);
}
function createApiServerLogStream(logFilePath) {
mkdirSync(dirname(logFilePath), { recursive: true });
const logStream = createWriteStream(logFilePath, {
flags: 'a',
encoding: 'utf8',
});
logStream.on('error', (error) => {
console.error(`[api-server] 写入日志失败: ${error.message}`);
});
return logStream;
}
function writeLauncherLog(logStream, message, stream = process.stdout) {
const line = `${message}\n`;
stream.write(line);
if (!logStream.destroyed) {
logStream.write(line);
}
}
function stopExistingWindowsApiServer(logStream) {
if (process.platform !== 'win32') {
return;
}
// Windows 下 cargo 重新编译时无法覆盖仍在运行的 exe,只清理本仓库 target 内的旧进程。
const command = [
'$ErrorActionPreference = "Continue"',
'$target = [System.IO.Path]::GetFullPath($env:GENARRATIVE_API_SERVER_EXE_TARGET)',
'$processes = Get-Process -Name api-server -ErrorAction SilentlyContinue | Where-Object {',
' $_.Path -and ([System.IO.Path]::GetFullPath($_.Path) -ieq $target)',
'}',
'foreach ($process in $processes) {',
' try {',
' Stop-Process -Id $process.Id -Force -ErrorAction Stop',
' Wait-Process -Id $process.Id -Timeout 5 -ErrorAction SilentlyContinue',
' Write-Output $process.Id',
' } catch {',
' Write-Error "[api-server] 忽略旧进程清理瞬时失败 pid=$($process.Id): $($_.Exception.Message)"',
' }',
'}',
'exit 0',
].join('\n');
const output = execFileSync(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command],
{
encoding: 'utf8',
env: {
...process.env,
GENARRATIVE_API_SERVER_EXE_TARGET: apiServerExePath,
},
},
).trim();
if (output) {
writeLauncherLog(logStream, `[api-server] 已停止旧 api-server 进程: ${output}`);
}
}
function main() {
const mergedEnv = mergeApiServerEnv(repoRoot);
mergedEnv.GENARRATIVE_API_HOST =
mergedEnv.GENARRATIVE_API_HOST || '127.0.0.1';
mergedEnv.GENARRATIVE_API_PORT = mergedEnv.GENARRATIVE_API_PORT || '3100';
mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL =
mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL || 'http://127.0.0.1:3101';
mergedEnv.GENARRATIVE_SPACETIME_DATABASE =
mergedEnv.GENARRATIVE_SPACETIME_DATABASE || '';
mergedEnv.GENARRATIVE_SPACETIME_TOKEN =
mergedEnv.GENARRATIVE_SPACETIME_TOKEN || '';
const logFilePath = resolveApiServerLogFile(repoRoot, mergedEnv);
const logStream = createApiServerLogStream(logFilePath);
mergedEnv.GENARRATIVE_API_SERVER_LOG_FILE = logFilePath;
let didExit = false;
const exitAfterLogFlush = (code) => {
const finish = () => {
if (didExit) {
return;
}
didExit = true;
process.exit(code);
};
if (logStream.destroyed) {
finish();
return;
}
logStream.end(finish);
setTimeout(finish, 1000).unref();
};
writeLauncherLog(logStream, `[api-server] 日志: ${logFilePath}`);
if (!mergedEnv.GENARRATIVE_SPACETIME_DATABASE) {
writeLauncherLog(
logStream,
'[api-server] 缺少 GENARRATIVE_SPACETIME_DATABASE。',
process.stderr,
);
exitAfterLogFlush(1);
return;
}
try {
stopExistingWindowsApiServer(logStream);
} catch (error) {
writeLauncherLog(
logStream,
`[api-server] 清理旧 api-server 进程失败: ${error.message}`,
process.stderr,
);
exitAfterLogFlush(1);
return;
}
writeLauncherLog(
logStream,
`[api-server] SpacetimeDB ${mergedEnv.GENARRATIVE_SPACETIME_DATABASE} @ ${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`,
);
const child = spawn(
'cargo',
['run', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'],
{
cwd: repoRoot,
env: mergedEnv,
stdio: ['inherit', 'pipe', 'pipe'],
},
);
child.stdout?.on('data', (chunk) => {
process.stdout.write(chunk);
logStream.write(chunk);
});
child.stderr?.on('data', (chunk) => {
process.stderr.write(chunk);
logStream.write(chunk);
});
child.on('error', (error) => {
writeLauncherLog(
logStream,
`[api-server] 启动 cargo 失败: ${error.message}`,
process.stderr,
);
exitAfterLogFlush(1);
});
child.on('close', (code, signal) => {
if (signal) {
writeLauncherLog(
logStream,
`[api-server] api-server 被信号终止: ${signal}`,
process.stderr,
);
exitAfterLogFlush(1);
return;
}
exitAfterLogFlush(code ?? 0);
});
}
if (
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
main();
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { mergeApiServerEnv } from './api-server-dev.mjs';
import {mergeApiServerEnv} from './dev-utils.mjs';
const REQUIRED_FOR_PUZZLE_GENERATION = [
'VECTOR_ENGINE_BASE_URL',
@@ -48,4 +48,4 @@ if (missing.length > 0) {
process.exit(1);
}
console.log('[api-server-env] 配置齐全。重启 npm run api-server 或 npm run dev 后生效。');
console.log('[api-server-env] 配置齐全。重启 npm run dev:api-server 或 npm run dev 后生效。');
@@ -174,7 +174,7 @@ function buildReport({
'',
'本次实测:',
'',
'- `npm run api-server` 可启动 Rust `api-server`。',
'- `npm run dev:api-server` 可启动 Rust `api-server`。',
'- `GET http://127.0.0.1:3100/healthz` 返回 `200`,响应为 `{"ok":true,"service":"genarrative-api-server"}`。',
'- `GET /api/runtime/visual-novel/gallery` 在当前本地环境返回超时 / `502`,日志显示 `api-server` 连接 `127.0.0.1:3101` SpacetimeDB 数据库 `xushi-p4wfr` 被拒绝;该项按本地 SpacetimeDB 未完整就绪记录为环境阻塞,不新增工程实现。',
'',
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -5,7 +5,11 @@
当前正式开发入口统一为:
- `npm run dev`
- `scripts/dev-rust-stack.sh`
- `npm run dev:spacetime`
- `npm run dev:api-server`
- `npm run dev:web`
- `npm run dev:admin-web`
- `scripts/dev.mjs`
- `server-rs/crates/api-server/**`
- `server-rs/crates/spacetime-module/**`
+108
View File
@@ -0,0 +1,108 @@
import {existsSync, mkdirSync, readFileSync} from 'node:fs';
import {dirname, isAbsolute, resolve} from 'node:path';
export const LOCAL_ENV_FILES = ['.env', '.env.local', '.env.secrets.local'];
export function buildProtectedEnvKeys(baseEnv) {
return new Set(
Object.entries(baseEnv)
.filter(([, value]) => String(value ?? '').trim())
.map(([key]) => key),
);
}
export function loadEnvFile(path, target, protectedKeys) {
if (!existsSync(path)) {
return;
}
const rawText = readFileSync(path, 'utf8');
for (const rawLine of rawText.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
if (!match) {
continue;
}
const [, key, rawValue] = match;
if (protectedKeys.has(key)) {
continue;
}
target[key] = rawValue.replace(/^['"]|['"]$/gu, '');
}
}
export function loadApiServerEnv(repoRootPath, target, protectedKeys) {
const resolvedProtectedKeys =
protectedKeys ?? buildProtectedEnvKeys(process.env);
// shell > .env > .env.local > .env.secrets.local
for (const fileName of LOCAL_ENV_FILES) {
loadEnvFile(resolve(repoRootPath, fileName), target, resolvedProtectedKeys);
}
}
export function mergeApiServerEnv(repoRootPath, baseEnv = process.env) {
const mergedEnv = {...baseEnv};
loadApiServerEnv(repoRootPath, mergedEnv, buildProtectedEnvKeys(baseEnv));
return mergedEnv;
}
export function formatApiServerLogTimestamp(date = new Date()) {
const pad = (value) => String(value).padStart(2, '0');
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
'-',
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join('');
}
export function resolveApiServerLogFile(
repoRootPath,
env = process.env,
now = new Date(),
) {
const explicitLogFile = String(
env.GENARRATIVE_API_SERVER_LOG_FILE ?? '',
).trim();
if (explicitLogFile) {
return isAbsolute(explicitLogFile)
? explicitLogFile
: resolve(repoRootPath, explicitLogFile);
}
const logDir =
String(env.GENARRATIVE_API_SERVER_LOG_DIR ?? '').trim() ||
'logs/api-server';
const resolvedLogDir = isAbsolute(logDir)
? logDir
: resolve(repoRootPath, logDir);
return resolve(
resolvedLogDir,
`api-server-${formatApiServerLogTimestamp(now)}.log`,
);
}
export function ensureParentDir(filePath) {
mkdirSync(dirname(filePath), {recursive: true});
}
export function resolveClientHost(hostName) {
if (hostName === '0.0.0.0' || hostName === '::') {
return '127.0.0.1';
}
return hostName;
}
@@ -8,7 +8,7 @@ import {
formatApiServerLogTimestamp,
mergeApiServerEnv,
resolveApiServerLogFile,
} from './api-server-dev.mjs';
} from './dev-utils.mjs';
type EnvMap = Record<string, string>;
@@ -29,7 +29,7 @@ function withTempEnvFiles(
}
}
describe('api-server-dev env merge', () => {
describe('dev utils env merge', () => {
test('.env.local 和 .env.secrets.local 可以覆盖 .env 默认值', () => {
withTempEnvFiles(
{
@@ -97,7 +97,7 @@ describe('api-server-dev env merge', () => {
});
});
describe('api-server-dev log file resolution', () => {
describe('dev utils log file resolution', () => {
const fixedDate = new Date(2026, 4, 15, 6, 7, 8);
test('默认写入 logs/api-server 的时间戳文件', () => {
-172
View File
@@ -1,172 +0,0 @@
import {spawn} from 'node:child_process';
import {existsSync, readFileSync} from 'node:fs';
import {resolve} from 'node:path';
import {
findAvailablePort,
formatPortDecision,
normalizePort,
} from './dev-stack-port-utils.mjs';
const repoRoot = process.cwd();
const shellEnvKeys = new Set(
Object.entries(process.env)
.filter(([, value]) => String(value ?? '').trim())
.map(([key]) => key),
);
function loadEnvFile(path, target) {
if (!existsSync(path)) {
return;
}
const rawText = readFileSync(path, 'utf8');
for (const rawLine of rawText.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
if (!match) {
continue;
}
const [, key, rawValue] = match;
// 中文注释:命令行显式传入的目标优先,`.env.local` 再覆盖 `.env`,与 api-server 启动脚本保持一致。
if (shellEnvKeys.has(key)) {
continue;
}
target[key] = rawValue.replace(/^['"]|['"]$/gu, '');
}
}
const fileEnv = {...process.env};
loadEnvFile(resolve(repoRoot, '.env'), fileEnv);
loadEnvFile(resolve(repoRoot, '.env.local'), fileEnv);
loadEnvFile(resolve(repoRoot, '.env.secrets.local'), fileEnv);
function buildTargetCandidates() {
const candidates = [
fileEnv.GENARRATIVE_RUNTIME_SERVER_TARGET,
fileEnv.RUST_SERVER_TARGET,
fileEnv.GENARRATIVE_API_TARGET,
`http://127.0.0.1:${fileEnv.GENARRATIVE_API_PORT || '3100'}`,
'http://127.0.0.1:8082',
'http://127.0.0.1:3100',
].filter(Boolean);
return Array.from(new Set(candidates));
}
async function isTargetReachable(target) {
const healthUrl = new URL('/healthz', target);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1200);
try {
const response = await fetch(healthUrl, {
method: 'GET',
signal: controller.signal,
});
return response.ok;
} catch {
return false;
} finally {
clearTimeout(timeoutId);
}
}
async function resolveRuntimeTarget() {
const candidates = buildTargetCandidates();
const reachableTargets = [];
for (const target of candidates) {
if (await isTargetReachable(target)) {
reachableTargets.push(target);
if (
target === fileEnv.GENARRATIVE_RUNTIME_SERVER_TARGET ||
target === fileEnv.RUST_SERVER_TARGET ||
target === fileEnv.GENARRATIVE_API_TARGET
) {
return {
target,
fallbackUsed: false,
};
}
}
}
if (reachableTargets.length > 0) {
return {
target: reachableTargets[0],
fallbackUsed: true,
};
}
return {
target:
fileEnv.GENARRATIVE_RUNTIME_SERVER_TARGET ||
fileEnv.RUST_SERVER_TARGET ||
fileEnv.GENARRATIVE_API_TARGET ||
`http://127.0.0.1:${fileEnv.GENARRATIVE_API_PORT || '3100'}`,
fallbackUsed: false,
};
}
const runtimeTarget = await resolveRuntimeTarget();
if (runtimeTarget.fallbackUsed) {
console.warn(
`[dev:web] 配置的 Rust target 不可用,已切换到 ${runtimeTarget.target}`,
);
}
const mergedEnv = {
...fileEnv,
RUST_SERVER_TARGET: runtimeTarget.target,
GENARRATIVE_RUNTIME_SERVER_TARGET: runtimeTarget.target,
};
console.log(`[dev:web] backend=rust target=${mergedEnv.GENARRATIVE_RUNTIME_SERVER_TARGET}`);
const webHost = '0.0.0.0';
const preferredWebPort = normalizePort(fileEnv.WEB_PORT, 3000);
const webPort = await findAvailablePort({
host: webHost,
preferredPort: preferredWebPort,
});
console.log(
formatPortDecision({
name: 'web',
host: webHost,
preferredPort: preferredWebPort,
resolvedPort: webPort,
}),
);
const child = spawn(
'node',
['scripts/vite-cli.mjs', `--port=${webPort}`, `--host=${webHost}`, '--strictPort'],
{
cwd: process.cwd(),
env: mergedEnv,
stdio: 'inherit',
shell: process.platform === 'win32',
},
);
child.on('error', (error) => {
console.error(`[dev:web] 启动 Vite 失败: ${error.message}`);
process.exit(1);
});
child.on('exit', (code, signal) => {
if (signal) {
console.error(`[dev:web] Vite 被信号终止: ${signal}`);
process.exit(1);
}
process.exit(code ?? 0);
});
+1497
View File
File diff suppressed because it is too large Load Diff
+244
View File
@@ -0,0 +1,244 @@
import {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {afterEach, describe, expect, test, vi} from 'vitest';
import {
DevRunner,
createDevServerSpawnOptions,
createWatchConfigs,
parseArgs,
shouldAcceptWatchEvent,
} from './dev.mjs';
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('dev scheduler argument routing', () => {
test('完整 dev 栈覆盖前端代理到本次解析出的 api-server 地址', () => {
const {command, explicitOptions, options} = parseArgs([], {
GENARRATIVE_API_PORT: '8090',
GENARRATIVE_RUNTIME_SERVER_TARGET: 'http://127.0.0.1:3100',
RUST_SERVER_TARGET: 'http://127.0.0.1:3100',
GENARRATIVE_API_TARGET: 'http://127.0.0.1:3100',
});
const runner = new DevRunner(options, {}, explicitOptions);
runner.command = command;
expect(runner.resolveFrontendApiTarget()).toBe('http://127.0.0.1:8090');
});
test('单独 dev:web 未显式指定 api 参数时沿用已有 Rust target', () => {
const testEnv = {
RUST_SERVER_TARGET: 'http://127.0.0.1:3100',
GENARRATIVE_API_PORT: '8082',
};
const {command, explicitOptions, options} = parseArgs(['web'], testEnv);
const runner = new DevRunner(options, testEnv, explicitOptions);
runner.command = command;
expect(runner.resolveFrontendApiTarget()).toBe('http://127.0.0.1:3100');
});
test('单独 dev:web 显式指定 api-port 时覆盖代理目标', () => {
const testEnv = {
RUST_SERVER_TARGET: 'http://127.0.0.1:3100',
GENARRATIVE_API_PORT: '8082',
};
const {command, explicitOptions, options} = parseArgs(
['web', '--api-port', '9090'],
testEnv,
);
const runner = new DevRunner(options, testEnv, explicitOptions);
runner.command = command;
expect(runner.resolveFrontendApiTarget()).toBe('http://127.0.0.1:9090');
});
test('单独 dev:admin-web 优先沿用 ADMIN_API_TARGET', () => {
const testEnv = {
ADMIN_API_TARGET: 'http://127.0.0.1:3100',
RUST_SERVER_TARGET: 'http://127.0.0.1:8082',
};
const {command, explicitOptions, options} = parseArgs(['admin-web'], testEnv);
const runner = new DevRunner(options, testEnv, explicitOptions);
runner.command = command;
expect(runner.resolveFrontendApiTarget({admin: true})).toBe(
'http://127.0.0.1:3100',
);
});
});
describe('dev scheduler spacetime reuse guard', () => {
test('记录 URL 可 ping 但没有 spacetime.pid 时不复用宿主', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));
try {
writeFileSync(join(tempDir, 'dev-spacetime-url'), 'http://127.0.0.1:3199\n', 'utf8');
globalThis.fetch = vi.fn(async () => ({status: 200})) as unknown as typeof fetch;
const {command, explicitOptions, options} = parseArgs(
['--spacetime-data-dir', tempDir],
{},
);
const runner = new DevRunner(options, {}, explicitOptions);
await runner.tryReuseExistingSpacetime(command);
expect(runner.state.spacetimeReused).toBeUndefined();
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:3101');
} finally {
rmSync(tempDir, {recursive: true, force: true});
}
});
test('记录 URL 可 ping 且 spacetime.pid 存活时复用宿主', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));
try {
writeFileSync(join(tempDir, 'dev-spacetime-url'), 'http://127.0.0.1:3199\n', 'utf8');
writeFileSync(join(tempDir, 'spacetime.pid'), `${process.pid}\n`, 'utf8');
globalThis.fetch = vi.fn(async () => ({status: 200})) as unknown as typeof fetch;
const {command, explicitOptions, options} = parseArgs(
['--spacetime-data-dir', tempDir],
{},
);
const runner = new DevRunner(options, {}, explicitOptions);
await runner.tryReuseExistingSpacetime(command);
expect(runner.state.spacetimeReused).toBe(true);
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:3199');
expect(runner.options.spacetimePort).toBe(3199);
} finally {
rmSync(tempDir, {recursive: true, force: true});
}
});
test('没有 URL 记录但 spacetime.pid 存活时复用默认宿主', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));
try {
writeFileSync(join(tempDir, 'spacetime.pid'), `${process.pid}\n`, 'utf8');
globalThis.fetch = vi.fn(async () => ({status: 200})) as unknown as typeof fetch;
const {command, explicitOptions, options} = parseArgs(
['--spacetime-data-dir', tempDir, '--spacetime-port', '3198'],
{},
);
const runner = new DevRunner(options, {}, explicitOptions);
await runner.tryReuseExistingSpacetime(command);
expect(runner.state.spacetimeReused).toBe(true);
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:3198');
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://127.0.0.1:3198/v1/ping',
expect.any(Object),
);
} finally {
rmSync(tempDir, {recursive: true, force: true});
}
});
test('spacetime.pid 存活但候选地址不可访问时不继续启动第二个宿主', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));
try {
writeFileSync(join(tempDir, 'spacetime.pid'), `${process.pid}\n`, 'utf8');
globalThis.fetch = vi.fn(async () => ({status: 503})) as unknown as typeof fetch;
const {command, explicitOptions, options} = parseArgs(
['--spacetime-data-dir', tempDir, '--spacetime-port', '3198'],
{},
);
const runner = new DevRunner(options, {}, explicitOptions);
await expect(runner.tryReuseExistingSpacetime(command)).rejects.toThrow(
'检测到 spacetime.pid',
);
expect(runner.state.spacetimeReused).toBeUndefined();
} finally {
rmSync(tempDir, {recursive: true, force: true});
}
});
});
describe('dev scheduler interactive input', () => {
test('前端 dev server 不继承 stdin,避免吞掉 rs 重启命令', () => {
const options = createDevServerSpawnOptions({cwd: repoRootForTest(), env: {A: 'B'}});
expect(options.stdio).toEqual(['ignore', 'pipe', 'pipe']);
expect(options.env).toEqual({A: 'B'});
});
});
function repoRootForTest() {
return process.cwd();
}
describe('dev scheduler watch routing', () => {
test('watch 模式不重启 web/admin-web,交给 Vite 自身 watch', () => {
const configs = createWatchConfigs();
expect(configs.web).toEqual([]);
expect(configs['admin-web']).toEqual([]);
});
test('watch 过滤依赖缓存和构建产物,避免自触发循环', () => {
const config = {
path: join(process.cwd(), 'apps/admin-web'),
filter: () => true,
};
expect(
shouldAcceptWatchEvent(config, join(process.cwd(), 'apps/admin-web/src/App.tsx')),
).toBe(true);
expect(
shouldAcceptWatchEvent(
config,
join(process.cwd(), 'apps/admin-web/node_modules/.vite/deps/_metadata.json'),
),
).toBe(false);
expect(
shouldAcceptWatchEvent(config, join(process.cwd(), 'apps/admin-web/dist/assets/app.js')),
).toBe(false);
});
});
describe('dev scheduler spacetime refresh', () => {
test('手动刷新 spacetime 只重新发布模块,不重启 standalone 进程', async () => {
const {explicitOptions, options} = parseArgs([], {});
const runner = new DevRunner(options, {}, explicitOptions);
const restart = vi.fn();
runner.services.set('spacetime', {restart});
runner.waitForSpacetime = vi.fn(async () => {});
runner.publishSpacetimeModule = vi.fn(async () => {});
await runner.restartService('spacetime');
expect(restart).not.toHaveBeenCalled();
expect(runner.waitForSpacetime).toHaveBeenCalledTimes(1);
expect(runner.publishSpacetimeModule).toHaveBeenCalledTimes(1);
});
test('skip-publish 时 spacetime 刷新不会重启或发布', async () => {
const {explicitOptions, options} = parseArgs(['--skip-publish'], {});
const runner = new DevRunner(options, {}, explicitOptions);
const restart = vi.fn();
runner.services.set('spacetime', {restart});
runner.waitForSpacetime = vi.fn(async () => {});
runner.publishSpacetimeModule = vi.fn(async () => {});
await runner.restartService('spacetime');
expect(restart).not.toHaveBeenCalled();
expect(runner.waitForSpacetime).not.toHaveBeenCalled();
expect(runner.publishSpacetimeModule).not.toHaveBeenCalled();
});
});
+2 -2
View File
@@ -5,8 +5,8 @@ set -euo pipefail
usage() {
cat <<'EOF'
用法:
npm run dev:rust:logs
npm run dev:rust:logs -- --follow
npm run dev:spacetime:logs
npm run dev:spacetime:logs -- --follow
./scripts/spacetime-logs-local.sh --lines 500 --output logs/spacetime/local.log
说明: