合并远端主分支
同步外部 MCP 项目选择与 API 契约修复 同步 Gitea CI 网络稳定性与构建镜像配置
This commit is contained in:
@@ -8,7 +8,7 @@ All paths below are relative to `https://www.genarrative.world`. Discovery and S
|
||||
|
||||
| Operation | Method and path | Minimum input |
|
||||
| --- | --- | --- |
|
||||
| List projects | `GET /api/external/v1/editor/projects` | Authentication |
|
||||
| List projects | `GET /api/external/v1/editor/projects` | Authentication; optional `view=full\|summary` (default `full`) |
|
||||
| Create project | `POST /api/external/v1/editor/projects` | Optional `title` |
|
||||
| Load recent project | `GET /api/external/v1/editor/projects/recent` | Authentication |
|
||||
| Get project | `GET /api/external/v1/editor/projects/{projectId}` | `projectId` |
|
||||
@@ -19,6 +19,13 @@ All paths below are relative to `https://www.genarrative.world`. Discovery and S
|
||||
|
||||
Canvas save uses optimistic revision control. Pass the last authoritative `expectedRevision`; on conflict, reload instead of replaying a stale full layout.
|
||||
|
||||
Project listing supports two views:
|
||||
|
||||
- `view=full` is the REST default and returns the complete project, canvas, layers, and resources.
|
||||
- `view=summary` returns only `projectId`, `title`, `updatedAt`, and nullable `cover`, so callers can display, search, disambiguate same-name projects, and select a safe target without loading every canvas snapshot.
|
||||
- Hosted MCP `list_editor_projects` always uses `summary`; call `get_editor_project` after selecting a `projectId` when complete authoritative state is required.
|
||||
- `cover` contains only `resourceId`, stable `objectKey`, dimensions, and `updatedAt`. It never embeds image bytes, a Data URL, or a signed URL. To display it, pass `cover.objectKey` to `get_external_asset_read_url`; signed URLs are temporary and must not be persisted or reused as generation references.
|
||||
|
||||
## Asset and Upload Operations
|
||||
|
||||
| Operation | Method and path | Minimum input |
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||
|
||||
- name: Run repository lint gates
|
||||
run: npm run lint
|
||||
@@ -101,10 +101,10 @@ jobs:
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||
|
||||
- name: Install AI game creator dependencies
|
||||
run: npm ci --prefix apps/ai-game-creator-shell
|
||||
run: bash scripts/ci-npm-ci-with-retry.sh --prefix apps/ai-game-creator-shell
|
||||
|
||||
- name: Run frontend and script tests
|
||||
run: npm run test
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||
|
||||
- name: Prepare server-rs Rust dependencies
|
||||
shell: bash
|
||||
@@ -204,10 +204,10 @@ jobs:
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||
|
||||
- name: Install AI game creator dependencies
|
||||
run: npm ci --prefix apps/ai-game-creator-shell
|
||||
run: bash scripts/ci-npm-ci-with-retry.sh --prefix apps/ai-game-creator-shell
|
||||
|
||||
- name: Prepare native Rust dependencies
|
||||
shell: bash
|
||||
|
||||
+1
@@ -4570,6 +4570,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -67,13 +67,13 @@ Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `ht
|
||||
```bash
|
||||
bash scripts/gitea-ci-job-image.sh build
|
||||
bash scripts/gitea-ci-job-image.sh verify
|
||||
bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/genarrative-gitea-project-ci-20260723.1.tar.zst
|
||||
bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/genarrative-gitea-project-ci-20260807.1.tar.zst
|
||||
bash scripts/gitea-ci-job-image.sh load-runner
|
||||
```
|
||||
|
||||
默认构建 tag 为 `genarrative/gitea-project-ci:20260723.1`。脚本通过 NUL 分隔白名单 tar 流只发送 Dockerfile、checkout 脚本、npm manifests/lock 和 Cargo manifests/lock;当前构建 context 约 `1.638 MB`,不会把业务源码、素材或本地私密文件发送给 Docker daemon。镜像除固定工具链外,还按当前根 `package-lock.json`、`server-rs/Cargo.lock` 和桌面壳 `Cargo.lock` 预热 npm / Cargo 下载缓存;两个 `cargo fetch --locked` 最多执行 5 次整命令级有界重试,再以断网 `cargo fetch --locked` 验证缓存闭合,不包含 `node_modules` 或 Cargo `target`。`build` 完成后会自动运行环境校验,`load-runner` 还会比对宿主和 runner 内层的完整 Image ID,并在内层执行 bwrap 与 Chrome headless canary。当前验证镜像约 `1.788 GB`,完整 Image ID 为 `sha256:c04b114b1f145072c9df7842c4c974e1bb2eaaf391d95d84c9212a460546b7d5`。执行这些命令不要求必须使用 root,但执行账号必须有权访问宿主 Docker API 并管理 runner 容器;没有该权限时交给 runner 运维人员执行。
|
||||
默认构建 tag 为 `genarrative/gitea-project-ci:20260807.1`。脚本通过 NUL 分隔白名单 tar 流只发送 Dockerfile、checkout 脚本、根与 AI 游戏创作壳的 npm manifests/lock,以及 server-rs、桌面壳和 AI 游戏创作壳的 Cargo manifests/lock;当前构建 context 约 `2.13 MB`,不会把业务源码、素材或本地私密文件发送给 Docker daemon。镜像除固定工具链外,还按上述五份 lock 预热 npm / Cargo 下载缓存;三个 `cargo fetch --locked` 最多执行 5 次整命令级有界重试,再分别以断网 `cargo fetch --locked` 验证缓存闭合,不包含 `node_modules` 或 Cargo `target`。`build` 完成后会自动运行环境校验,`load-runner` 还会比对宿主和 runner 内层的完整 Image ID,并在内层执行 bwrap 与 Chrome headless canary。当前验证镜像约 `1.85 GB`,完整 Image ID 为 `sha256:8b4b30f5a096522942947927b06cf47bdb1a1016dde9a3573f9780e79d8e40cf`。执行这些命令不要求必须使用 root,但执行账号必须有权访问宿主 Docker API 并管理 runner 容器;没有该权限时交给 runner 运维人员执行。
|
||||
|
||||
runner 配置保留原 `ubuntu-latest` 映射,另外增加 `genarrative-ci:docker://sha256:c04b114b1f145072c9df7842c4c974e1bb2eaaf391d95d84c9212a460546b7d5`。内层 Docker 数据必须持久化,`force_pull` 保持 `false`;该精确 Image ID 在内层不存在时 job 应直接失败,不回退到浮动 tag 或现场拉取。四个 job 使用镜像内 `genarrative-gitea-checkout` 直接从当前 Gitea 拉取事件 commit,带 5 次有界重试,不再运行时下载 GitHub checkout action;随后以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,同时校验工具链、缓存锁命中状态、bwrap 和 Chrome headless。它们仍各自运行干净的 `npm ci` 以校验当前 lockfile 并隔离 PR 依赖,但使用镜像内 npm cache 和 `prefer-offline`;锁文件新增依赖时允许经受控网络补齐,本阶段不启用共享 Actions cache。
|
||||
runner 配置保留原 `ubuntu-latest` 映射,另外增加 `genarrative-ci:docker://sha256:8b4b30f5a096522942947927b06cf47bdb1a1016dde9a3573f9780e79d8e40cf`。内层 Docker 数据必须持久化,`force_pull` 保持 `false`;该精确 Image ID 在内层不存在时 job 应直接失败,不回退到浮动 tag 或现场拉取。四个 job 使用镜像内 `genarrative-gitea-checkout` 直接从当前 Gitea 拉取事件 commit,带 5 次有界重试,不再运行时下载 GitHub checkout action;随后以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,同时校验工具链、五份缓存锁命中状态、bwrap 和 Chrome headless。锁不匹配时校验会输出 `partial` 和醒目的 Actions warning,提示在可信分支落地后刷新镜像。各 job 仍运行干净的 `npm ci` 以校验当前 lockfile 并隔离 PR 依赖,但统一通过 `scripts/ci-npm-ci-with-retry.sh` 最多执行 3 次整命令级有界重试,并使用镜像内 npm cache 和 `prefer-offline`;锁文件新增依赖时允许经受控网络补齐,本阶段不启用共享 Actions cache。
|
||||
|
||||
更新顺序固定为:
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ ENV CARGO_HTTP_MULTIPLEXING=false \
|
||||
|
||||
COPY server-rs /tmp/genarrative-cargo-cache/server-rs
|
||||
COPY apps/desktop-shell/src-tauri /tmp/genarrative-cargo-cache/desktop-shell
|
||||
COPY apps/ai-game-creator-shell/src-tauri /tmp/genarrative-cargo-cache/apps/ai-game-creator-shell/src-tauri
|
||||
|
||||
RUN find /tmp/genarrative-cargo-cache -name Cargo.toml -exec dirname {} \; \
|
||||
| while IFS= read -r crate_dir; do \
|
||||
@@ -39,12 +40,16 @@ RUN find /tmp/genarrative-cargo-cache -name Cargo.toml -exec dirname {} \; \
|
||||
} \
|
||||
&& cargo_fetch_with_retry /tmp/genarrative-cargo-cache/server-rs/Cargo.toml \
|
||||
&& cargo_fetch_with_retry /tmp/genarrative-cargo-cache/desktop-shell/Cargo.toml \
|
||||
&& cargo_fetch_with_retry /tmp/genarrative-cargo-cache/apps/ai-game-creator-shell/src-tauri/Cargo.toml \
|
||||
&& CARGO_NET_OFFLINE=true cargo fetch --locked \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
--manifest-path /tmp/genarrative-cargo-cache/server-rs/Cargo.toml \
|
||||
&& CARGO_NET_OFFLINE=true cargo fetch --locked \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
--manifest-path /tmp/genarrative-cargo-cache/desktop-shell/Cargo.toml \
|
||||
&& CARGO_NET_OFFLINE=true cargo fetch --locked \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
--manifest-path /tmp/genarrative-cargo-cache/apps/ai-game-creator-shell/src-tauri/Cargo.toml \
|
||||
&& rm -rf /tmp/genarrative-cargo-cache
|
||||
|
||||
FROM ${RUNNER_IMAGE}
|
||||
@@ -123,21 +128,31 @@ COPY --from=rust-dependency-cache /usr/local/cargo /usr/local/cargo
|
||||
COPY --from=rust-dependency-cache /usr/local/rustup /usr/local/rustup
|
||||
|
||||
ARG NPM_LOCK_SHA256
|
||||
ARG AGC_NPM_LOCK_SHA256
|
||||
ARG SERVER_RUST_LOCK_SHA256
|
||||
ARG DESKTOP_RUST_LOCK_SHA256
|
||||
ARG AGC_RUST_LOCK_SHA256
|
||||
|
||||
COPY package.json package-lock.json /usr/local/share/genarrative-ci/npm/
|
||||
COPY apps/ai-game-creator-shell/package.json apps/ai-game-creator-shell/package-lock.json /usr/local/share/genarrative-ci/agc-npm/
|
||||
COPY server-rs/Cargo.lock /usr/local/share/genarrative-ci/locks/server-rs.Cargo.lock
|
||||
COPY apps/desktop-shell/src-tauri/Cargo.lock /usr/local/share/genarrative-ci/locks/desktop-shell.Cargo.lock
|
||||
COPY apps/ai-game-creator-shell/src-tauri/Cargo.lock /usr/local/share/genarrative-ci/locks/ai-game-creator-shell.Cargo.lock
|
||||
COPY deploy/container/gitea-ci-checkout.sh /usr/local/bin/genarrative-gitea-checkout
|
||||
|
||||
RUN test -n "${NPM_LOCK_SHA256}" \
|
||||
&& test -n "${AGC_NPM_LOCK_SHA256}" \
|
||||
&& test -n "${SERVER_RUST_LOCK_SHA256}" \
|
||||
&& test -n "${DESKTOP_RUST_LOCK_SHA256}" \
|
||||
&& test -n "${AGC_RUST_LOCK_SHA256}" \
|
||||
&& printf '%s %s\n' \
|
||||
"${NPM_LOCK_SHA256}" \
|
||||
/usr/local/share/genarrative-ci/npm/package-lock.json \
|
||||
| sha256sum --check --strict \
|
||||
&& printf '%s %s\n' \
|
||||
"${AGC_NPM_LOCK_SHA256}" \
|
||||
/usr/local/share/genarrative-ci/agc-npm/package-lock.json \
|
||||
| sha256sum --check --strict \
|
||||
&& printf '%s %s\n' \
|
||||
"${SERVER_RUST_LOCK_SHA256}" \
|
||||
/usr/local/share/genarrative-ci/locks/server-rs.Cargo.lock \
|
||||
@@ -146,6 +161,10 @@ RUN test -n "${NPM_LOCK_SHA256}" \
|
||||
"${DESKTOP_RUST_LOCK_SHA256}" \
|
||||
/usr/local/share/genarrative-ci/locks/desktop-shell.Cargo.lock \
|
||||
| sha256sum --check --strict \
|
||||
&& printf '%s %s\n' \
|
||||
"${AGC_RUST_LOCK_SHA256}" \
|
||||
/usr/local/share/genarrative-ci/locks/ai-game-creator-shell.Cargo.lock \
|
||||
| sha256sum --check --strict \
|
||||
&& chmod 0755 /usr/local/bin/genarrative-gitea-checkout \
|
||||
&& npm ci \
|
||||
--ignore-scripts \
|
||||
@@ -154,6 +173,13 @@ RUN test -n "${NPM_LOCK_SHA256}" \
|
||||
--prefer-offline \
|
||||
--prefix /usr/local/share/genarrative-ci/npm \
|
||||
&& rm -rf /usr/local/share/genarrative-ci/npm/node_modules \
|
||||
&& npm ci \
|
||||
--ignore-scripts \
|
||||
--no-audit \
|
||||
--no-fund \
|
||||
--prefer-offline \
|
||||
--prefix /usr/local/share/genarrative-ci/agc-npm \
|
||||
&& rm -rf /usr/local/share/genarrative-ci/agc-npm/node_modules \
|
||||
&& npm cache verify
|
||||
|
||||
RUN install -m 0755 /usr/local/cargo/bin/rustup /usr/local/bin/rustup \
|
||||
@@ -162,6 +188,8 @@ RUN install -m 0755 /usr/local/cargo/bin/rustup /usr/local/bin/rustup \
|
||||
done
|
||||
|
||||
ENV CARGO_HOME=/usr/local/cargo \
|
||||
GENARRATIVE_GITEA_CI_AGC_NPM_LOCK_SHA256=${AGC_NPM_LOCK_SHA256} \
|
||||
GENARRATIVE_GITEA_CI_AGC_RUST_LOCK_SHA256=${AGC_RUST_LOCK_SHA256} \
|
||||
GENARRATIVE_GITEA_CI_DESKTOP_RUST_LOCK_SHA256=${DESKTOP_RUST_LOCK_SHA256} \
|
||||
GENARRATIVE_GITEA_CI_NPM_LOCK_SHA256=${NPM_LOCK_SHA256} \
|
||||
GENARRATIVE_GITEA_CI_SERVER_RUST_LOCK_SHA256=${SERVER_RUST_LOCK_SHA256} \
|
||||
@@ -178,6 +206,7 @@ ENV CARGO_HOME=/usr/local/cargo \
|
||||
|
||||
ARG IMAGE_REVISION=uncommitted
|
||||
LABEL org.opencontainers.image.vendor="GenarrativeAI" \
|
||||
org.opencontainers.image.version="2026.08.07.1" \
|
||||
org.opencontainers.image.source="https://git.genarrative.world/GenarrativeAI/Genarrative" \
|
||||
org.opencontainers.image.revision="${IMAGE_REVISION}" \
|
||||
org.opencontainers.image.base.name="docker.gitea.com/runner-images:ubuntu-latest@sha256:58ea92624c7c09582e05594d95488331045053d3a3f34cf09649f2a32313a614" \
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
!server-rs/crates/*/
|
||||
!server-rs/crates/*/Cargo.toml
|
||||
!apps/
|
||||
!apps/ai-game-creator-shell/
|
||||
!apps/ai-game-creator-shell/package.json
|
||||
!apps/ai-game-creator-shell/package-lock.json
|
||||
!apps/ai-game-creator-shell/src-tauri/
|
||||
!apps/ai-game-creator-shell/src-tauri/Cargo.toml
|
||||
!apps/ai-game-creator-shell/src-tauri/Cargo.lock
|
||||
!apps/desktop-shell/
|
||||
!apps/desktop-shell/src-tauri/
|
||||
!apps/desktop-shell/src-tauri/Cargo.toml
|
||||
|
||||
@@ -365,13 +365,36 @@
|
||||
"ExternalApiKey": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "view",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "返回视图。full 返回完整项目、画布、图层与资源;summary 只返回项目选择所需元数据和封面稳定引用。MCP 的 list_editor_projects 工具固定使用 summary。",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"full",
|
||||
"summary"
|
||||
],
|
||||
"default": "full"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "项目列表",
|
||||
"description": "项目列表。view=full 返回完整项目列表;view=summary 返回紧凑项目摘要列表。",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExternalEditorProjectListResponse"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ExternalEditorProjectListResponse"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ExternalEditorProjectSummaryListResponse"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2432,6 +2455,87 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExternalEditorProjectSummaryListResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"projects"
|
||||
],
|
||||
"properties": {
|
||||
"projects": {
|
||||
"type": "array",
|
||||
"description": "用于展示、查找、同名确认和安全选择目标的紧凑项目摘要;不包含 canvas、viewport、layers 或 resources。",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/EditorProjectSummary"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EditorProjectSummary": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"projectId",
|
||||
"title",
|
||||
"updatedAt",
|
||||
"cover"
|
||||
],
|
||||
"properties": {
|
||||
"projectId": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"cover": {
|
||||
"description": "项目最新封面快照的稳定引用;项目没有封面时为 null。需要展示时使用 objectKey 调用 /assets/read-url 获取临时签名 URL。",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/EditorProjectSummaryCover"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EditorProjectSummaryCover": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"resourceId",
|
||||
"objectKey",
|
||||
"width",
|
||||
"height",
|
||||
"updatedAt"
|
||||
],
|
||||
"properties": {
|
||||
"resourceId": {
|
||||
"type": "string"
|
||||
},
|
||||
"objectKey": {
|
||||
"type": "string",
|
||||
"description": "封面对象的稳定引用,不是图片正文、Data URL 或临时签名 URL。"
|
||||
},
|
||||
"width": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"height": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ExternalEditorProjectDeleteResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -4016,11 +4016,18 @@
|
||||
|
||||
- 现象:`Repository checks`、`Frontend tests`、`Backend tests` 和 `Native shell tests` 都从全新 job 容器开始,apt、setup-node、rustup 和原生系统库在不同 job 里重复安装;后端与原生壳的安装时间可达数分钟,并把软件源和代理瞬时失败放大为四份。
|
||||
- 原因:Gitea Actions job 彼此隔离,上一个 job 在容器内安装的包不会自动进入下一个 job;把同一套不随 PR 变化的工具链写在 workflow step 中,必然每次重做。
|
||||
- 处理:用 `deploy/container/gitea-ci-job.Dockerfile` 预装 Node 22、Rust 1.96、`rustfmt`、Chrome、`bwrap`、`rg`、`ffmpeg`、`clang/lld` 和 Tauri / 后端系统依赖,并按锁预热根 npm、server-rs 与桌面壳 Cargo 下载缓存。四个 job 统一 `runs-on: genarrative-ci`,先用镜像内脚本直接从 Gitea checkout,再以 runtime 模式运行 `scripts/check-gitea-ci-job-image.sh`,同时检查缓存锁、工具链、完整 bwrap 与 Chrome headless。`RUSTUP_AUTO_INSTALL=0`;`rust-toolchain.toml` 变更时先重建镜像,不把下载 fallback 放回 job。
|
||||
- 处理:用 `deploy/container/gitea-ci-job.Dockerfile` 预装 Node 22、Rust 1.96、`rustfmt`、Chrome、`bwrap`、`rg`、`ffmpeg`、`clang/lld` 和 Tauri / 后端系统依赖,并按锁预热根与 AI 游戏创作壳 npm、server-rs、桌面壳与 AI 游戏创作壳 Cargo 下载缓存。四个 job 统一 `runs-on: genarrative-ci`,先用镜像内脚本直接从 Gitea checkout,再以 runtime 模式运行 `scripts/check-gitea-ci-job-image.sh`,同时检查五份缓存锁、工具链、完整 bwrap 与 Chrome headless。`RUSTUP_AUTO_INSTALL=0`;`rust-toolchain.toml` 变更时先重建镜像,不把下载 fallback 放回 job。
|
||||
- 依赖边界:每个 job 仍必须各自执行 `npm ci`,让当前 lockfile 和 PR 依赖在干净环境中验证;区别是命中镜像 cache 时只做本地解包,锁新增依赖时才走受控网络。不要把 `node_modules` 或 Cargo `target` 烘进镜像,也不要向不受信任 PR 挂载跨 job 可写 cache。
|
||||
- 锁漂移边界:runtime 校验输出 `server_rust_cache_lock=partial` 说明镜像内 Cargo lock 与当前 checkout 不同,不代表新增 crate 已经缓存。必须在新镜像中以 `--network none` 对当前 lock 执行真实 `cargo fetch/build --offline`;`cargo metadata --no-deps` 不会证明依赖 archive 可用,不能作为替代。
|
||||
- 锁漂移边界:runtime 校验输出任一 `*_cache_lock=partial` 说明镜像内 lock 与当前 checkout 不同,不代表新增依赖已经缓存;必须同时输出 Actions warning,提示可信分支落地后刷新镜像。必须在新镜像中对 server-rs、桌面壳和 AI 游戏创作壳当前 lock 执行真实 `cargo fetch --locked --offline`;`cargo metadata --no-deps` 不会证明依赖 archive 可用,不能作为替代。
|
||||
- 构建网络边界:`CARGO_NET_RETRY` 只覆盖部分 crate 下载,registry `config.json` / index TLS 握手仍可能直接终止整次 fetch。Dockerfile 对每个 `cargo fetch --locked` 再做最多 5 次整命令级有界重试,最终仍执行断网 fetch,不能降低为无锁重试或省略离线闭合验证。
|
||||
- 验证:workflow 不再出现 GitHub checkout action、apt、setup-node 或 rustup 安装 step;镜像在 `--network none` 下能按当前 npm / Cargo lock 完成依赖准备,四个 job 的环境校验、干净 `npm ci` 和原有测试门禁仍全部执行。
|
||||
- 验证:workflow 不再出现 GitHub checkout action、apt、setup-node 或 rustup 安装 step;镜像能按五份当前 lock 完成缓存闭合,四个 job 的环境校验、经 3 次整命令级有界重试保护的干净 `npm ci` 和原有测试门禁仍全部执行。
|
||||
|
||||
## Gitea Actions HTTPS CONNECT 隧道必须双向收束 socket(2026-08-07)
|
||||
|
||||
- 现象:CI 的 `npm ci` 高频出现 `ECONNRESET / network aborted`,Cargo 则出现 crates.io TLS EOF、连接超时或下载失败;同一出口 gateway 容器看似健康,却累计自动重启数百次,日志反复出现 `Socket.ondata -> Writable.write -> write EPIPE -> Unhandled 'error' event`。
|
||||
- 原因:HTTPS CONNECT 建立后使用 `upstreamSocket.pipe(clientSocket)` 与反向 pipe,但只监听 upstream `error`;客户端在 DNS 等待、下载或 job 清理期间关闭连接时,pipe 继续向已断开的 client socket 写入,未处理的 EPIPE 会让 Node 进程退出。`unless-stopped` 自动拉起和浅层 healthcheck 会掩盖崩溃,所有并发 npm / Cargo 隧道同时被 reset。
|
||||
- 处理:CONNECT 一开始就为 client socket 注册 `error / close`,解析完成后为 upstream socket注册同样的双向销毁处理;DNS 返回、写 200 和开始 pipe 前都检查 client 是否已销毁。任一端 error、close 或 timeout 都幂等 destroy 两端,不把普通客户端 reset 写成错误日志。不要用进程级 `uncaughtException` 吞掉问题,也不要只增加 npm/Cargo 重试掩盖 gateway 崩溃。
|
||||
- 验证:在独立 canary 和正式 gateway 上分别并发制造至少 500 次“CONNECT 后立即断开”,随后确认容器仍运行、restart count 不增加、日志无 EPIPE;再通过同一 proxy 对 npm registry 与 crates index 建立完整 TLS 隧道。切换前仍须确认 Gitea 无活跃 run 且 Runner 内层无 job 容器。
|
||||
|
||||
## Gitea CI 预构建镜像不能只靠 tag 判断内容
|
||||
|
||||
@@ -4208,6 +4215,13 @@
|
||||
- 验证:覆盖“服务端已入队但提交响应丢失”后两次 POST 的 endpoint、正文 bytes 与 `Idempotency-Key` 完全相同,原键重试仍返回同一 operation,最终只出现一份 completed result 和一次计费 / 写回;恢复再次 transport 失败或临时鉴权失败仍保留同一账本;换 owner 不可见;MCP 与 REST 对同一 owner、同一请求和同一键必须命中同一 operation。
|
||||
- 关联:`server-rs/crates/api-server/src/external_generation.rs`、`server-rs/crates/api-server/src/external_mcp.rs`、`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`。
|
||||
|
||||
## MCP 列表不能透传完整项目快照(2026-08-07)
|
||||
|
||||
- 现象:账号项目数量增长后,`list_editor_projects` 把每个项目的 `canvas / layers / resources` 全量透传,REST 响应超过 MCP 4 MiB 上限,Agent 因整批失败而无法展示、查重或安全选择项目;缺少必填请求体时,内部 Axum JSON extractor 的文本 `415` 又会被泛化成“非 JSON 响应”。
|
||||
- 处理:项目列表 REST 保持默认 `view=full` 兼容,并提供 `view=summary`;MCP 固定使用 summary 且不向 Agent 暴露或接受 `view=full`。摘要只返回 `projectId / title / updatedAt / cover`,封面取最新且存在稳定 `objectKey` 的 `project-cover-snapshot`,展示时再调用 `/assets/read-url`,不在列表内嵌图片或签名 URL。MCP 在构造内部 REST 请求前按 OpenAPI schema 校验 required body;缺正文和缺字段分别返回结构化错误,不进入写入、上传票据或计费路径。
|
||||
- 验证:用 19 个完整序列化后超过 4 MiB 的项目 fixture 证明摘要仍低于上限且不含大型布局;覆盖四个历史 `415` 工具的缺正文、空对象和非对象输入,并断言项目列表工具固定 summary、调用方不能通过 query 覆盖。
|
||||
- 关联:`server-rs/crates/api-server/src/external_mcp.rs`、`server-rs/crates/api-server/src/external_editor_api.rs`、`docs/openapi/genarrative-external-v1.openapi.json`、`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`。
|
||||
|
||||
## api-server 嵌入仓库外资源时必须同步容器构建上下文(2026-07-31)
|
||||
|
||||
- 现象:本地 `cargo test` 可以编译 MCP 与 Skill 下载模块,但 api-server 镜像在 Rust 编译阶段报 `include_str!` 找不到 OpenAPI 或 Skill 文件。
|
||||
|
||||
@@ -17,7 +17,7 @@ v1 只开放以下能力:
|
||||
- `POST /api/external/v1/assets/direct-upload-tickets`:创建素材直传 OSS 凭证。
|
||||
- `POST /api/external/v1/assets/objects/confirm`:确认已上传素材对象,`ownerUserId` 固定为 API Key 所属账号。
|
||||
- `GET /api/external/v1/assets/read-url`:获取私有素材读取签名 URL。
|
||||
- `GET /api/external/v1/editor/projects`:列出当前 API Key 所属账号的图片画布项目。
|
||||
- `GET /api/external/v1/editor/projects`:列出当前 API Key 所属账号的图片画布项目;`view=full|summary`,REST 默认 `full`,MCP 固定使用 `summary`。
|
||||
- `POST /api/external/v1/editor/projects`:创建图片画布项目。
|
||||
- `GET /api/external/v1/editor/projects/recent`:读取当前账号最近图片画布项目。
|
||||
- `GET /api/external/v1/editor/projects/{projectId}`:读取项目与默认画布。
|
||||
@@ -83,6 +83,8 @@ MCP transport 的 DNS rebinding 防护必须同时允许正式入口 `www.genarr
|
||||
|
||||
MCP tools 从同一份 OpenAPI operation 自动形成 snake_case 名称,并在进程内复用 External REST router,因此鉴权、scope、owner、入参、幂等、计费和结果查询契约只有一份。生成 tools 把 `idempotencyKey` 显式放进参数,因为 MCP transport 的 Authorization 头不能代替逐次业务幂等键。工具结果使用 `structuredContent`;业务失败使用 `isError=true` 的结构化安全错误,协议不可路由时才返回 JSON-RPC error。
|
||||
|
||||
`list_editor_projects` 是项目选择工具,服务端固定以 `view=summary` 调用项目列表,不允许因 OpenAPI 的 REST 默认值退回完整视图。摘要逐项目只返回 `projectId`、`title`、`updatedAt` 和可空 `cover`,不携带 `canvas`、`viewport`、`layers`、`resources` 或图片正文;选定目标后再用 `get_editor_project` 读取完整权威状态。`cover` 只包含最新项目封面快照的 `resourceId`、稳定 `objectKey`、尺寸与 `updatedAt`,没有封面时为 `null`。需要展示封面时,以 `objectKey` 调用 `/api/external/v1/assets/read-url` 获取短期签名 URL;列表不得内嵌 Data URL、图片二进制或临时签名 URL,也不得把签名 URL 当作持久引用。
|
||||
|
||||
MCP 暴露下列稳定文本资源:
|
||||
|
||||
- `genarrative://external-editor/usage`:关键工作流和异步轮询规则。
|
||||
@@ -257,6 +259,8 @@ docs/openapi/genarrative-external-v1.openapi.json
|
||||
- 角色图、图标 spritesheet 和 UI 素材提取的 completed result 允许携带 `EditorGenerationWarning`;provider 原图保留降级与自动拆分降级必须保持成功状态,并分别使用通用 `warning` 与兼容 `sliceWarning` 表达。
|
||||
- 外部视频、角色动画、音效和音乐接口使用站内编辑器相同的请求校验、模型限制和价格校验。
|
||||
- OpenAPI JSON 能被 `serde_json` 解析,且 security scheme 为 Bearer API Key。
|
||||
- 项目列表 REST 默认 `view=full` 并保持完整响应兼容;`view=summary` 只返回项目选择元数据和可空封面稳定引用,MCP `list_editor_projects` 固定使用该摘要视图,不因完整项目数据量增长触发返回体上限。
|
||||
- 摘要封面不内嵌图片或签名 URL;使用 `cover.objectKey` 调 `/assets/read-url` 后才能临时展示。
|
||||
- OpenAPI JSON 不包含 `/api/profile/api-keys`、`UserAccessToken` 或 API Key 管理 schema。
|
||||
- `agent-integration.json` 能发现 MCP、OpenAPI、Skill entry/archive;下载 archive 的 SHA-256 与 manifest 一致,ZIP 包含 `SKILL.md`、四篇 references、Python helper 和 `agents/openai.yaml` 七个声明文件且不含凭据。
|
||||
- MCP 在无 Bearer、Bearer 格式错误或 Key 无效时返回相同的 `401 + WWW-Authenticate + details.guide` 鉴权引导,且不暴露 tools/resources/owner;合法 Key 可完成 initialize、tools/list、resources/list/read 和生成提交/查询;resource catalog 必须包含 usage、OpenAPI、`skill` 主入口和当前全部 Skill references,当前精确为 `skill/references/capability-routing.md`、`skill/references/api-operations.md`、`skill/references/authentication-and-safety.md` 与 `skill/references/requests-and-outputs.md`,且不包含 CLI 脚本、测试或 workflow;多实例不依赖 sticky session,不暴露内部 SpacetimeDB MCP 或 worker 控制面。
|
||||
|
||||
@@ -243,20 +243,20 @@ PR checkout 必须保留完整 Git 历史,并把 PR base SHA 传给 `SPACETIME
|
||||
|
||||
当前 `genarrative-station` 使用 Gitea `1.26.4` 和基于 Gitea Runner `2.0.0-dind-rootless` 的固定 digest 修补镜像。Runner 2.0.0 会先把 `systempaths=unconfined` 解析为空 `MaskedPaths` / `ReadonlyPaths`,再被 `mergo.WithOverride` 当成 empty value 丢失;站点修补只在 merge 后保留这两个显式空 slice,不改其它 runner 行为。真实 job inspect 必须看到 `MaskedPaths=[]`、`ReadonlyPaths=[]`、`SecurityOpt=[seccomp=unconfined]`、`Privileged=false`、无 CapAdd 且 `Binds=[]`。外层 runner 以 `rootless` 用户运行,`privileged=false`、不增加 `CAP_SYS_ADMIN`,只映射 `/dev/net/tun`,内部 Docker 只监听私有 Unix socket;runner 配置保持 `docker_host: "-"`、`valid_volumes: []`、`bind_workdir: false` 和 `force_pull: false`,防止内部 Docker socket 或宿主 bind mount 进入 job。job 只连接 `gitea-actions` internal network:`genarrative-station` 由只转发 `/git` 到 Gitea 的内部 gateway 解析,公网依赖只经拒绝私网、保留地址和 metadata 的 80/443 egress proxy;绕过 proxy 的公网和 Postgres/Redis 数据网都必须不可达。完整 bwrap canary 需要 rootless DinD 外层的 rootlesskit AppArmor/userns 边界,以及内层 job 的 namespace/proc 挂载支持;相关 `seccomp/systempaths` 放宽只允许存在于这个无宿主 socket 的 rootless DinD 内层,禁止复制回控制宿主 rootful Docker 的 runner。
|
||||
|
||||
CI job 镜像由 `deploy/container/gitea-ci-job.Dockerfile` 定义:Ubuntu job base 固定为 `sha256:58ea92624c7c09582e05594d95488331045053d3a3f34cf09649f2a32313a614`,Rust stage 固定为 `sha256:19817ead3289c8c631c73df281e18b59b172f6a31f4f563290f69cddd06c30e9`,Node `22.23.1` 发行包执行 SHA-256 校验,Google Linux 主签名指纹固定,Chrome 固定为 `150.0.7871.181-1`。构建脚本以 NUL 分隔白名单 tar 流只发送 Dockerfile、checkout 脚本和 npm / Cargo manifests/lock;当前 context 约 `1.638 MB`。镜像按根 npm 锁、server-rs 锁和桌面壳锁预热下载缓存,不包含 `node_modules` 或 Cargo `target`;两个 `cargo fetch --locked` 在 Cargo 自身重试之外再执行最多 5 次整命令级有界重试,处理 registry index 握手失败,最终仍以断网 `cargo fetch --locked` 关闭验证。当前验证镜像约 `1.788 GB`,默认 tag 为 `genarrative/gitea-project-ci:20260723.1`,完整 Image ID 为 `sha256:c04b114b1f145072c9df7842c4c974e1bb2eaaf391d95d84c9212a460546b7d5`;runner 标签保留 `ubuntu-latest`,并将 `genarrative-ci` 映射到 `docker://sha256:c04b114b1f145072c9df7842c4c974e1bb2eaaf391d95d84c9212a460546b7d5`。内层 Docker 数据必须持久化;`force_pull: false` 表示只使用这个已装载的精确内容,Image ID 缺失时 job 必须失败关闭,不得回退浮动 tag 或临时连 registry。
|
||||
CI job 镜像由 `deploy/container/gitea-ci-job.Dockerfile` 定义:Ubuntu job base 固定为 `sha256:58ea92624c7c09582e05594d95488331045053d3a3f34cf09649f2a32313a614`,Rust stage 固定为 `sha256:19817ead3289c8c631c73df281e18b59b172f6a31f4f563290f69cddd06c30e9`,Node `22.23.1` 发行包执行 SHA-256 校验,Google Linux 主签名指纹固定,Chrome 固定为 `150.0.7871.181-1`。构建脚本以 NUL 分隔白名单 tar 流只发送 Dockerfile、checkout 脚本,以及根、AI 游戏创作壳、server-rs 与桌面壳所需的 npm / Cargo manifests/lock;当前 context 约 `2.13 MB`。镜像按根 npm 锁、AI 游戏创作壳 npm 锁、server-rs 锁、桌面壳锁和 AI 游戏创作壳 Cargo 锁预热下载缓存,不包含 `node_modules` 或 Cargo `target`;三个 `cargo fetch --locked` 在 Cargo 自身重试之外再执行最多 5 次整命令级有界重试,处理 registry index 握手失败,最终仍分别以断网 `cargo fetch --locked` 关闭验证。当前验证镜像约 `1.85 GB`,默认 tag 为 `genarrative/gitea-project-ci:20260807.1`,完整 Image ID 为 `sha256:8b4b30f5a096522942947927b06cf47bdb1a1016dde9a3573f9780e79d8e40cf`;runner 标签保留 `ubuntu-latest`,并将 `genarrative-ci` 映射到 `docker://sha256:8b4b30f5a096522942947927b06cf47bdb1a1016dde9a3573f9780e79d8e40cf`。内层 Docker 数据必须持久化;`force_pull: false` 表示只使用这个已装载的精确内容,Image ID 缺失时 job 必须失败关闭,不得回退浮动 tag 或临时连 registry。
|
||||
|
||||
镜像更新命令:
|
||||
|
||||
```bash
|
||||
bash scripts/gitea-ci-job-image.sh build
|
||||
bash scripts/gitea-ci-job-image.sh verify
|
||||
bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/genarrative-gitea-project-ci-20260723.1.tar.zst
|
||||
bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/genarrative-gitea-project-ci-20260807.1.tar.zst
|
||||
bash scripts/gitea-ci-job-image.sh load-runner
|
||||
```
|
||||
|
||||
执行账号只要有权访问宿主 Docker API 并管理 runner 容器即可,不强制使用 root;无该权限时由 runner 运维人员执行。更新顺序必须是 `build/verify -> export 仓库外镜像归档与 SHA-256 sidecar -> load-runner -> 确认无活跃 job -> 备份当前 config -> 增加或替换 label -> docker restart --timeout 660 gitea-runner`。`--timeout 660` 只是停止宽限,不是 drain API;rootless DinD supervisor 可能同时停止内层 dockerd,因此重启前必须确认 Gitea 没有 `in_progress` run 且内层 `docker ps` 为空。config 和镜像归档只保存到仓库外受控位置,不在文档、仓库或日志中记录注册信息。重启后先重跑真实 PR 的四个 job,复核隔离边界并确认全部通过,再清理旧镜像。回滚时先把 workflow 的 `runs-on` 改回 `ubuntu-latest`,再恢复 config 备份并重启 runner。
|
||||
|
||||
四个 job 先运行镜像内 `genarrative-gitea-checkout`,再以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,校验 Node 主版本、仓库 Rust toolchain、受信任 PATH、缓存锁命中状态、原生命令、pkg-config 依赖、完整 bwrap sandbox 和 Chrome headless。`RUSTUP_AUTO_INSTALL=0`,因此仓库 `rust-toolchain.toml` 变更必须先更新镜像,不能让 job 现场下载。每个 job 仍独立运行 `npm ci`,以当前 lockfile 为准验证 PR 依赖;`NPM_CONFIG_PREFER_OFFLINE=true` 且网络重试为 10 次,命中镜像 cache 时只做干净解包,lock 变化时允许补齐差量。不在镜像内烘入 `node_modules`,也不挂载跨 PR 可写缓存。任何 job 的 sandbox canary 失败都必须停止,不允许跳过。Cargo 通过受控 proxy 下载 lock 差量时继续关闭 HTTP multiplexing 并设置 `CARGO_NET_RETRY=10`。
|
||||
四个 job 先运行镜像内 `genarrative-gitea-checkout`,再以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,校验 Node 主版本、仓库 Rust toolchain、受信任 PATH、五份缓存锁命中状态、原生命令、pkg-config 依赖、完整 bwrap sandbox 和 Chrome headless。运行时发现锁不匹配时必须输出对应 `*_cache_lock=partial` 和 Actions warning,提示可信分支落地后刷新镜像,不能把陈旧缓存误报为闭合。`RUSTUP_AUTO_INSTALL=0`,因此仓库 `rust-toolchain.toml` 变更必须先更新镜像,不能让 job 现场下载。每个 job 仍独立运行 `npm ci`,以当前 lockfile 为准验证 PR 依赖;统一通过 `scripts/ci-npm-ci-with-retry.sh` 做最多 3 次整命令级有界重试,同时保留 `NPM_CONFIG_PREFER_OFFLINE=true` 和 npm 自身 10 次 fetch retry。命中镜像 cache 时只做干净解包,lock 变化时允许补齐差量。不在镜像内烘入 `node_modules`,也不挂载跨 PR 可写缓存。任何 job 的 sandbox canary 失败都必须停止,不允许跳过。Cargo 通过受控 proxy 下载 lock 差量时继续关闭 HTTP multiplexing,并设置 `CARGO_NET_RETRY=10`。
|
||||
|
||||
站点 stack 仍由宿主受控目录管理,`.env`、runner 注册文件和数据库凭据不进入仓库。Compose 必须在 helper/container 内把该目录挂到与宿主相同的绝对路径再执行;挂载到不同路径会让相对 bind source 被 Docker daemon 解析到错误的宿主目录并启动空数据。升级或 runner 迁移前先停止 Gitea 写入,并把 Gitea 冷快照、数据库导出、compose/env 与 runner config/.runner 保存到仓库外受控备份位置。备份文件、绝对宿主配置和注册 token 不得提交 Git,也不在共享文档中记录具体路径或注册内容。
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ verify_cache_lock() {
|
||||
fi
|
||||
if [[ "${GENARRATIVE_GITEA_CI_CHECK_RUNTIME:-0}" == '1' ]]; then
|
||||
printf '%s_cache_lock=partial\n' "${cache_name}"
|
||||
printf '::warning title=CI dependency cache is partial::%s lock differs from the prebuilt image; refresh the trusted CI image after this lock change lands.\n' "${cache_name}"
|
||||
return
|
||||
fi
|
||||
echo "${cache_name} cache lock does not match the verification checkout." >&2
|
||||
@@ -43,22 +44,34 @@ verify_cache_lock() {
|
||||
}
|
||||
|
||||
npm_lock_path="${repo_root}/package-lock.json"
|
||||
agc_npm_lock_path="${repo_root}/apps/ai-game-creator-shell/package-lock.json"
|
||||
server_rust_lock_path="${repo_root}/server-rs/Cargo.lock"
|
||||
desktop_rust_lock_path="${repo_root}/apps/desktop-shell/src-tauri/Cargo.lock"
|
||||
agc_rust_lock_path="${repo_root}/apps/ai-game-creator-shell/src-tauri/Cargo.lock"
|
||||
if [[ ! -f "${npm_lock_path}" ]]; then
|
||||
npm_lock_path='/usr/local/share/genarrative-ci/npm/package-lock.json'
|
||||
fi
|
||||
if [[ ! -f "${agc_npm_lock_path}" ]]; then
|
||||
agc_npm_lock_path='/usr/local/share/genarrative-ci/agc-npm/package-lock.json'
|
||||
fi
|
||||
if [[ ! -f "${server_rust_lock_path}" ]]; then
|
||||
server_rust_lock_path='/usr/local/share/genarrative-ci/locks/server-rs.Cargo.lock'
|
||||
fi
|
||||
if [[ ! -f "${desktop_rust_lock_path}" ]]; then
|
||||
desktop_rust_lock_path='/usr/local/share/genarrative-ci/locks/desktop-shell.Cargo.lock'
|
||||
fi
|
||||
if [[ ! -f "${agc_rust_lock_path}" ]]; then
|
||||
agc_rust_lock_path='/usr/local/share/genarrative-ci/locks/ai-game-creator-shell.Cargo.lock'
|
||||
fi
|
||||
|
||||
verify_cache_lock \
|
||||
npm \
|
||||
"${GENARRATIVE_GITEA_CI_NPM_LOCK_SHA256:-}" \
|
||||
"${npm_lock_path}"
|
||||
verify_cache_lock \
|
||||
agc_npm \
|
||||
"${GENARRATIVE_GITEA_CI_AGC_NPM_LOCK_SHA256:-}" \
|
||||
"${agc_npm_lock_path}"
|
||||
verify_cache_lock \
|
||||
server_rust \
|
||||
"${GENARRATIVE_GITEA_CI_SERVER_RUST_LOCK_SHA256:-}" \
|
||||
@@ -67,6 +80,10 @@ verify_cache_lock \
|
||||
desktop_rust \
|
||||
"${GENARRATIVE_GITEA_CI_DESKTOP_RUST_LOCK_SHA256:-}" \
|
||||
"${desktop_rust_lock_path}"
|
||||
verify_cache_lock \
|
||||
agc_rust \
|
||||
"${GENARRATIVE_GITEA_CI_AGC_RUST_LOCK_SHA256:-}" \
|
||||
"${agc_rust_lock_path}"
|
||||
|
||||
for command_name in \
|
||||
bwrap \
|
||||
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
max_attempts="${GENARRATIVE_CI_NPM_CI_ATTEMPTS:-3}"
|
||||
base_delay_seconds="${GENARRATIVE_CI_NPM_CI_RETRY_DELAY_SECONDS:-5}"
|
||||
|
||||
if [[ ! "${max_attempts}" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo 'GENARRATIVE_CI_NPM_CI_ATTEMPTS must be a positive integer.' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! "${base_delay_seconds}" =~ ^[0-9]+$ ]]; then
|
||||
echo 'GENARRATIVE_CI_NPM_CI_RETRY_DELAY_SECONDS must be a non-negative integer.' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
for attempt in $(seq 1 "${max_attempts}"); do
|
||||
if npm ci "$@"; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" -eq "${max_attempts}" ]]; then
|
||||
echo "npm ci failed after ${max_attempts} attempts." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep "$((attempt * base_delay_seconds))"
|
||||
done
|
||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
dockerfile_context_path="deploy/container/gitea-ci-job.Dockerfile"
|
||||
image_tag="${GENARRATIVE_GITEA_CI_IMAGE_TAG:-genarrative/gitea-project-ci:20260723.1}"
|
||||
image_tag="${GENARRATIVE_GITEA_CI_IMAGE_TAG:-genarrative/gitea-project-ci:20260807.1}"
|
||||
runner_container="${GENARRATIVE_GITEA_RUNNER_CONTAINER:-gitea-runner}"
|
||||
|
||||
write_build_context_file_list() {
|
||||
@@ -14,6 +14,10 @@ write_build_context_file_list() {
|
||||
deploy/container/gitea-ci-checkout.sh \
|
||||
package.json \
|
||||
package-lock.json \
|
||||
apps/ai-game-creator-shell/package.json \
|
||||
apps/ai-game-creator-shell/package-lock.json \
|
||||
apps/ai-game-creator-shell/src-tauri/Cargo.toml \
|
||||
apps/ai-game-creator-shell/src-tauri/Cargo.lock \
|
||||
server-rs/Cargo.toml \
|
||||
server-rs/Cargo.lock \
|
||||
apps/desktop-shell/src-tauri/Cargo.toml \
|
||||
@@ -61,6 +65,10 @@ case "${command_name}" in
|
||||
deploy/container/gitea-ci-checkout.sh \
|
||||
package.json \
|
||||
package-lock.json \
|
||||
apps/ai-game-creator-shell/package.json \
|
||||
apps/ai-game-creator-shell/package-lock.json \
|
||||
apps/ai-game-creator-shell/src-tauri/Cargo.toml \
|
||||
apps/ai-game-creator-shell/src-tauri/Cargo.lock \
|
||||
server-rs/Cargo.toml \
|
||||
server-rs/Cargo.lock \
|
||||
apps/desktop-shell/src-tauri/Cargo.toml \
|
||||
@@ -74,10 +82,14 @@ case "${command_name}" in
|
||||
)"
|
||||
npm_lock_sha256="$(sha256sum "${repo_root}/package-lock.json")"
|
||||
npm_lock_sha256="${npm_lock_sha256%% *}"
|
||||
agc_npm_lock_sha256="$(sha256sum "${repo_root}/apps/ai-game-creator-shell/package-lock.json")"
|
||||
agc_npm_lock_sha256="${agc_npm_lock_sha256%% *}"
|
||||
server_rust_lock_sha256="$(sha256sum "${repo_root}/server-rs/Cargo.lock")"
|
||||
server_rust_lock_sha256="${server_rust_lock_sha256%% *}"
|
||||
desktop_rust_lock_sha256="$(sha256sum "${repo_root}/apps/desktop-shell/src-tauri/Cargo.lock")"
|
||||
desktop_rust_lock_sha256="${desktop_rust_lock_sha256%% *}"
|
||||
agc_rust_lock_sha256="$(sha256sum "${repo_root}/apps/ai-game-creator-shell/src-tauri/Cargo.lock")"
|
||||
agc_rust_lock_sha256="${agc_rust_lock_sha256%% *}"
|
||||
(
|
||||
cd "${repo_root}"
|
||||
write_build_context_file_list \
|
||||
@@ -86,8 +98,10 @@ case "${command_name}" in
|
||||
--pull=false \
|
||||
--build-arg "IMAGE_REVISION=${image_revision}" \
|
||||
--build-arg "NPM_LOCK_SHA256=${npm_lock_sha256}" \
|
||||
--build-arg "AGC_NPM_LOCK_SHA256=${agc_npm_lock_sha256}" \
|
||||
--build-arg "SERVER_RUST_LOCK_SHA256=${server_rust_lock_sha256}" \
|
||||
--build-arg "DESKTOP_RUST_LOCK_SHA256=${desktop_rust_lock_sha256}" \
|
||||
--build-arg "AGC_RUST_LOCK_SHA256=${agc_rust_lock_sha256}" \
|
||||
--file "${dockerfile_context_path}" \
|
||||
--tag "${image_tag}" \
|
||||
-
|
||||
|
||||
@@ -7,6 +7,61 @@ const workflow = readFileSync(
|
||||
resolve(process.cwd(), '.gitea/workflows/project-ci.yml'),
|
||||
'utf8',
|
||||
);
|
||||
const imageBuildScript = readFileSync(
|
||||
resolve(process.cwd(), 'scripts/gitea-ci-job-image.sh'),
|
||||
'utf8',
|
||||
);
|
||||
const imageCheckScript = readFileSync(
|
||||
resolve(process.cwd(), 'scripts/check-gitea-ci-job-image.sh'),
|
||||
'utf8',
|
||||
);
|
||||
const npmCiRetryScript = readFileSync(
|
||||
resolve(process.cwd(), 'scripts/ci-npm-ci-with-retry.sh'),
|
||||
'utf8',
|
||||
);
|
||||
const imageDockerfile = readFileSync(
|
||||
resolve(process.cwd(), 'deploy/container/gitea-ci-job.Dockerfile'),
|
||||
'utf8',
|
||||
);
|
||||
const imageDockerignore = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
'deploy/container/gitea-ci-job.Dockerfile.dockerignore',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const jobNames = [
|
||||
'repository-checks',
|
||||
'frontend-tests',
|
||||
'backend-tests',
|
||||
'native-shell-tests',
|
||||
] as const;
|
||||
|
||||
function jobSection(jobName: (typeof jobNames)[number]) {
|
||||
const jobStart = workflow.indexOf(` ${jobName}:`);
|
||||
expect(jobStart).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const nextJobOffset = workflow
|
||||
.slice(jobStart + 1)
|
||||
.search(/^ {2}[a-z][a-z0-9-]+:$/m);
|
||||
return workflow.slice(
|
||||
jobStart,
|
||||
nextJobOffset < 0 ? undefined : jobStart + 1 + nextJobOffset,
|
||||
);
|
||||
}
|
||||
|
||||
function stepSection(jobName: (typeof jobNames)[number], stepName: string) {
|
||||
const job = jobSection(jobName);
|
||||
const stepStart = job.indexOf(` - name: ${stepName}`);
|
||||
expect(stepStart).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const nextStepOffset = job.slice(stepStart + 1).search(/^ {6}- name: /m);
|
||||
return job.slice(
|
||||
stepStart,
|
||||
nextStepOffset < 0 ? undefined : stepStart + 1 + nextStepOffset,
|
||||
);
|
||||
}
|
||||
|
||||
function backendStepIndex(stepName: string) {
|
||||
const backendJobStart = workflow.indexOf(' backend-tests:');
|
||||
@@ -20,6 +75,122 @@ function backendStepIndex(stepName: string) {
|
||||
}
|
||||
|
||||
describe('project CI workflow', () => {
|
||||
it('keeps every job on the isolated preinstalled CI image boundary', () => {
|
||||
expect(workflow.match(/^ {4}runs-on: genarrative-ci$/gm)).toHaveLength(4);
|
||||
expect(workflow).not.toContain('actions/checkout');
|
||||
expect(workflow).not.toContain('actions/setup-node');
|
||||
expect(workflow).not.toMatch(/^\s+run: .*\b(?:apt|rustup)\b/m);
|
||||
|
||||
for (const jobName of jobNames) {
|
||||
const job = jobSection(jobName);
|
||||
const checkout = job.indexOf('genarrative-gitea-checkout');
|
||||
const validateImage = job.indexOf(
|
||||
'GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh',
|
||||
);
|
||||
const installDependencies = job.indexOf(
|
||||
'- name: Install npm dependencies',
|
||||
);
|
||||
|
||||
expect(checkout).toBeGreaterThanOrEqual(0);
|
||||
expect(validateImage).toBeGreaterThan(checkout);
|
||||
expect(installDependencies).toBeGreaterThan(validateImage);
|
||||
}
|
||||
|
||||
expect(imageDockerfile).toMatch(
|
||||
/^ARG RUST_IMAGE=[^\s]+@sha256:[a-f0-9]{64}$/m,
|
||||
);
|
||||
expect(imageDockerfile).toMatch(
|
||||
/^ARG RUNNER_IMAGE=[^\s]+@sha256:[a-f0-9]{64}$/m,
|
||||
);
|
||||
});
|
||||
|
||||
it('retries every root and AI game creator npm clean install as a bounded whole command', () => {
|
||||
for (const jobName of jobNames) {
|
||||
const install = stepSection(jobName, 'Install npm dependencies');
|
||||
expect(install).toContain('bash scripts/ci-npm-ci-with-retry.sh');
|
||||
expect(install).not.toContain('--prefix');
|
||||
}
|
||||
|
||||
for (const jobName of ['frontend-tests', 'native-shell-tests'] as const) {
|
||||
const install = stepSection(
|
||||
jobName,
|
||||
'Install AI game creator dependencies',
|
||||
);
|
||||
expect(install).toContain(
|
||||
'bash scripts/ci-npm-ci-with-retry.sh --prefix apps/ai-game-creator-shell',
|
||||
);
|
||||
}
|
||||
|
||||
expect(npmCiRetryScript).toContain(
|
||||
'max_attempts="${GENARRATIVE_CI_NPM_CI_ATTEMPTS:-3}"',
|
||||
);
|
||||
expect(npmCiRetryScript).toContain(
|
||||
'base_delay_seconds="${GENARRATIVE_CI_NPM_CI_RETRY_DELAY_SECONDS:-5}"',
|
||||
);
|
||||
expect(npmCiRetryScript).toContain(
|
||||
'for attempt in $(seq 1 "${max_attempts}"); do',
|
||||
);
|
||||
expect(npmCiRetryScript).toContain('if npm ci "$@"; then');
|
||||
expect(npmCiRetryScript).toContain(
|
||||
'if [[ "${attempt}" -eq "${max_attempts}" ]]; then',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds and verifies image caches against both AI game creator locks', () => {
|
||||
const npmManifest = 'apps/ai-game-creator-shell/package.json';
|
||||
const npmLock = 'apps/ai-game-creator-shell/package-lock.json';
|
||||
const rustManifest = 'apps/ai-game-creator-shell/src-tauri/Cargo.toml';
|
||||
const rustLock = 'apps/ai-game-creator-shell/src-tauri/Cargo.lock';
|
||||
|
||||
for (const [path, expectedCount] of [
|
||||
[npmManifest, 2],
|
||||
[npmLock, 3],
|
||||
[rustManifest, 2],
|
||||
[rustLock, 3],
|
||||
] as const) {
|
||||
expect(imageBuildScript.split(path)).toHaveLength(expectedCount + 1);
|
||||
expect(imageDockerignore).toContain(`!${path}`);
|
||||
}
|
||||
|
||||
expect(imageBuildScript).toContain(
|
||||
'--build-arg "AGC_NPM_LOCK_SHA256=${agc_npm_lock_sha256}"',
|
||||
);
|
||||
expect(imageBuildScript).toContain(
|
||||
'--build-arg "AGC_RUST_LOCK_SHA256=${agc_rust_lock_sha256}"',
|
||||
);
|
||||
expect(imageDockerfile).toContain('ARG AGC_NPM_LOCK_SHA256');
|
||||
expect(imageDockerfile).toContain('ARG AGC_RUST_LOCK_SHA256');
|
||||
expect(imageDockerfile).toContain(
|
||||
'--prefix /usr/local/share/genarrative-ci/agc-npm',
|
||||
);
|
||||
expect(
|
||||
imageDockerfile.match(
|
||||
/--manifest-path \/tmp\/genarrative-cargo-cache\/apps\/ai-game-creator-shell\/src-tauri\/Cargo\.toml/g,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(imageDockerfile).toContain(
|
||||
'cargo_fetch_with_retry /tmp/genarrative-cargo-cache/apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
||||
);
|
||||
expect(imageDockerfile).toContain(
|
||||
'GENARRATIVE_GITEA_CI_AGC_NPM_LOCK_SHA256=${AGC_NPM_LOCK_SHA256}',
|
||||
);
|
||||
expect(imageDockerfile).toContain(
|
||||
'GENARRATIVE_GITEA_CI_AGC_RUST_LOCK_SHA256=${AGC_RUST_LOCK_SHA256}',
|
||||
);
|
||||
|
||||
expect(imageCheckScript).toContain(npmLock);
|
||||
expect(imageCheckScript).toContain(rustLock);
|
||||
expect(imageCheckScript).toContain(
|
||||
'${GENARRATIVE_GITEA_CI_AGC_NPM_LOCK_SHA256:-}',
|
||||
);
|
||||
expect(imageCheckScript).toContain(
|
||||
'${GENARRATIVE_GITEA_CI_AGC_RUST_LOCK_SHA256:-}',
|
||||
);
|
||||
expect(imageCheckScript).toContain(
|
||||
'::warning title=CI dependency cache is partial::',
|
||||
);
|
||||
});
|
||||
|
||||
it('prepares locked server-rs dependencies before the first Cargo build gate', () => {
|
||||
const prepareDependencies = backendStepIndex(
|
||||
'Prepare server-rs Rust dependencies',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State, rejection::JsonRejection},
|
||||
extract::{Extension, Path, Query, State, rejection::JsonRejection},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
@@ -16,7 +16,7 @@ use spacetime_client::{
|
||||
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
|
||||
EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput,
|
||||
EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
|
||||
EditorProjectGetRecordInput, EditorProjectRenameRecordInput,
|
||||
EditorProjectGetRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
|
||||
EditorProjectResourceCreateRecordInput, ExternalGenerationJobGetRecordInput,
|
||||
ExternalGenerationJobRecord, SpacetimeClientError,
|
||||
};
|
||||
@@ -65,6 +65,22 @@ const OPENAPI_JSON: &str =
|
||||
include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json");
|
||||
const EXTERNAL_GENERATION_POLL_AFTER_MS: u64 = 1_500;
|
||||
const IDEMPOTENCY_KEY_HEADER: &str = "idempotency-key";
|
||||
const PROJECT_COVER_SNAPSHOT_ASSET_KIND: &str = "project-cover-snapshot";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum ExternalEditorProjectListView {
|
||||
#[default]
|
||||
Full,
|
||||
Summary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorProjectListQuery {
|
||||
#[serde(default)]
|
||||
view: ExternalEditorProjectListView,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -168,6 +184,31 @@ pub struct ExternalEditorProjectListResponse {
|
||||
projects: Vec<EditorProjectPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorProjectSummary {
|
||||
project_id: String,
|
||||
title: String,
|
||||
updated_at: String,
|
||||
cover: Option<ExternalEditorProjectCoverSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorProjectCoverSummary {
|
||||
resource_id: String,
|
||||
object_key: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorProjectSummaryListResponse {
|
||||
projects: Vec<ExternalEditorProjectSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorProjectDeleteResponse {
|
||||
@@ -240,6 +281,7 @@ pub async fn create_external_editor_project(
|
||||
|
||||
pub async fn list_external_editor_projects(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ExternalEditorProjectListQuery>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(principal): Extension<ExternalApiPrincipal>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
@@ -248,15 +290,65 @@ pub async fn list_external_editor_projects(
|
||||
.spacetime_client()
|
||||
.list_editor_projects(principal.owner_user_id().to_string())
|
||||
.await
|
||||
.map_err(map_editor_project_error)?
|
||||
.into_iter()
|
||||
.map(editor_project_payload_from_record)
|
||||
.collect();
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ExternalEditorProjectListResponse { projects },
|
||||
))
|
||||
match query.view {
|
||||
ExternalEditorProjectListView::Full => Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ExternalEditorProjectListResponse {
|
||||
projects: projects
|
||||
.into_iter()
|
||||
.map(editor_project_payload_from_record)
|
||||
.collect(),
|
||||
},
|
||||
)),
|
||||
ExternalEditorProjectListView::Summary => Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ExternalEditorProjectSummaryListResponse {
|
||||
projects: projects
|
||||
.into_iter()
|
||||
.map(external_editor_project_summary_from_record)
|
||||
.collect(),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn external_editor_project_summary_from_record(
|
||||
record: EditorProjectRecord,
|
||||
) -> ExternalEditorProjectSummary {
|
||||
let cover = record
|
||||
.resources
|
||||
.iter()
|
||||
.filter_map(|resource| {
|
||||
(resource.asset_kind.as_deref() == Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND))
|
||||
.then_some(resource)
|
||||
.zip(
|
||||
resource
|
||||
.object_key
|
||||
.as_deref()
|
||||
.filter(|object_key| !object_key.trim().is_empty()),
|
||||
)
|
||||
})
|
||||
.max_by(|(left, _), (right, _)| {
|
||||
left.updated_at
|
||||
.cmp(&right.updated_at)
|
||||
.then_with(|| left.resource_id.cmp(&right.resource_id))
|
||||
})
|
||||
.map(|(resource, object_key)| ExternalEditorProjectCoverSummary {
|
||||
resource_id: resource.resource_id.clone(),
|
||||
object_key: object_key.to_string(),
|
||||
width: resource.width,
|
||||
height: resource.height,
|
||||
updated_at: resource.updated_at.clone(),
|
||||
});
|
||||
|
||||
ExternalEditorProjectSummary {
|
||||
project_id: record.project_id,
|
||||
title: record.title,
|
||||
updated_at: record.updated_at,
|
||||
cover,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_recent_external_editor_project(
|
||||
@@ -1026,6 +1118,75 @@ fn serialize_external_editor_image_sequence_frames(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use spacetime_client::{
|
||||
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectResourceRecord,
|
||||
};
|
||||
|
||||
fn external_editor_project_resource_fixture(
|
||||
resource_id: &str,
|
||||
asset_kind: Option<&str>,
|
||||
object_key: Option<&str>,
|
||||
updated_at: &str,
|
||||
) -> EditorProjectResourceRecord {
|
||||
serde_json::from_value(json!({
|
||||
"resource_id": resource_id,
|
||||
"project_id": "proj-summary-test",
|
||||
"owner_user_id": "user-1",
|
||||
"asset_object_id": format!("asset-object-{resource_id}"),
|
||||
"image_src": format!("/api/assets/{resource_id}"),
|
||||
"object_key": object_key,
|
||||
"width": 320,
|
||||
"height": 240,
|
||||
"source_type": "uploaded",
|
||||
"prompt": null,
|
||||
"actual_prompt": null,
|
||||
"model": null,
|
||||
"provider": null,
|
||||
"task_id": null,
|
||||
"source_resource_id": null,
|
||||
"asset_kind": asset_kind,
|
||||
"generation_inputs": null,
|
||||
"public_showcase_enabled": false,
|
||||
"created_at": updated_at,
|
||||
"updated_at": updated_at
|
||||
}))
|
||||
.expect("项目资源 fixture 应兼容可选字段扩展")
|
||||
}
|
||||
|
||||
fn external_editor_project_record_fixture(
|
||||
resources: Vec<EditorProjectResourceRecord>,
|
||||
) -> EditorProjectRecord {
|
||||
EditorProjectRecord {
|
||||
project_id: "proj-summary-test".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
title: "摘要项目".to_string(),
|
||||
canvas: EditorCanvasRecord {
|
||||
canvas_id: "canvas-summary-test".to_string(),
|
||||
project_id: "proj-summary-test".to_string(),
|
||||
title: "摘要项目".to_string(),
|
||||
viewport: EditorCanvasViewportRecord {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
scale: 1.0,
|
||||
},
|
||||
layers: json!([{"large": "canvas payload must not enter summary"}]),
|
||||
revision: 3,
|
||||
layout_storage_version: 1,
|
||||
background_color: None,
|
||||
created_at: "2026-08-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-08-07T00:00:00Z".to_string(),
|
||||
},
|
||||
viewport: EditorCanvasViewportRecord {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
scale: 1.0,
|
||||
},
|
||||
layers: json!([{"large": "project payload must not enter summary"}]),
|
||||
resources,
|
||||
created_at: "2026-08-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-08-07T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
const EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS: [&str; 2] = [
|
||||
"ExternalEditorAssetCreateRequest",
|
||||
@@ -1073,6 +1234,160 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_editor_project_list_query_defaults_to_full_and_accepts_summary() {
|
||||
let without_view = "http://localhost/api/external/v1/editor/projects"
|
||||
.parse()
|
||||
.expect("测试 URI 应合法");
|
||||
let Query(query) = Query::<ExternalEditorProjectListQuery>::try_from_uri(&without_view)
|
||||
.expect("缺省 view 应保持 full 兼容语义");
|
||||
assert_eq!(query.view, ExternalEditorProjectListView::Full);
|
||||
|
||||
let explicit_full = "http://localhost/api/external/v1/editor/projects?view=full"
|
||||
.parse()
|
||||
.expect("测试 URI 应合法");
|
||||
let Query(query) = Query::<ExternalEditorProjectListQuery>::try_from_uri(&explicit_full)
|
||||
.expect("显式 full 应合法");
|
||||
assert_eq!(query.view, ExternalEditorProjectListView::Full);
|
||||
|
||||
let summary = "http://localhost/api/external/v1/editor/projects?view=summary"
|
||||
.parse()
|
||||
.expect("测试 URI 应合法");
|
||||
let Query(query) = Query::<ExternalEditorProjectListQuery>::try_from_uri(&summary)
|
||||
.expect("summary 应合法");
|
||||
assert_eq!(query.view, ExternalEditorProjectListView::Summary);
|
||||
|
||||
let unknown = "http://localhost/api/external/v1/editor/projects?view=compact"
|
||||
.parse()
|
||||
.expect("测试 URI 应合法");
|
||||
assert!(
|
||||
Query::<ExternalEditorProjectListQuery>::try_from_uri(&unknown).is_err(),
|
||||
"未知 view 不得静默回落到 full"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_editor_project_summary_selects_latest_persisted_cover() {
|
||||
let project = external_editor_project_record_fixture(vec![
|
||||
external_editor_project_resource_fixture(
|
||||
"resource-cover-old",
|
||||
Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND),
|
||||
Some("editor/project-covers/old.webp"),
|
||||
"2026-08-02T00:00:00Z",
|
||||
),
|
||||
external_editor_project_resource_fixture(
|
||||
"resource-other-newer",
|
||||
Some("editor_generated_image"),
|
||||
Some("editor/generated/newer.webp"),
|
||||
"2026-08-06T00:00:00Z",
|
||||
),
|
||||
external_editor_project_resource_fixture(
|
||||
"resource-cover-empty-key",
|
||||
Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND),
|
||||
Some(" "),
|
||||
"2026-08-07T00:00:00Z",
|
||||
),
|
||||
external_editor_project_resource_fixture(
|
||||
"resource-cover-latest",
|
||||
Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND),
|
||||
Some("editor/project-covers/latest.webp"),
|
||||
"2026-08-05T00:00:00Z",
|
||||
),
|
||||
]);
|
||||
|
||||
let summary = external_editor_project_summary_from_record(project);
|
||||
let serialized = serde_json::to_value(&summary).expect("项目摘要应可序列化");
|
||||
|
||||
assert_eq!(
|
||||
serialized,
|
||||
json!({
|
||||
"projectId": "proj-summary-test",
|
||||
"title": "摘要项目",
|
||||
"updatedAt": "2026-08-07T00:00:00Z",
|
||||
"cover": {
|
||||
"resourceId": "resource-cover-latest",
|
||||
"objectKey": "editor/project-covers/latest.webp",
|
||||
"width": 320,
|
||||
"height": 240,
|
||||
"updatedAt": "2026-08-05T00:00:00Z"
|
||||
}
|
||||
})
|
||||
);
|
||||
assert!(serialized.get("canvas").is_none());
|
||||
assert!(serialized.get("layers").is_none());
|
||||
assert!(serialized.get("resources").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_editor_project_summary_returns_null_without_persisted_cover() {
|
||||
let project = external_editor_project_record_fixture(vec![
|
||||
external_editor_project_resource_fixture(
|
||||
"resource-non-cover",
|
||||
Some("editor_generated_image"),
|
||||
Some("editor/generated/image.webp"),
|
||||
"2026-08-06T00:00:00Z",
|
||||
),
|
||||
external_editor_project_resource_fixture(
|
||||
"resource-cover-without-object",
|
||||
Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND),
|
||||
None,
|
||||
"2026-08-07T00:00:00Z",
|
||||
),
|
||||
]);
|
||||
|
||||
let summary = external_editor_project_summary_from_record(project);
|
||||
assert_eq!(summary.cover, None);
|
||||
assert_eq!(
|
||||
serde_json::to_value(summary).expect("无封面摘要应可序列化")["cover"],
|
||||
Value::Null
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nineteen_large_projects_stay_below_mcp_limit_in_summary_view() {
|
||||
const MCP_LIMIT_BYTES: usize = 4 * 1024 * 1024;
|
||||
let projects = (0..19)
|
||||
.map(|index| {
|
||||
let mut project = external_editor_project_record_fixture(Vec::new());
|
||||
project.project_id = format!("proj-summary-{index}");
|
||||
project.canvas.project_id = project.project_id.clone();
|
||||
project.title = format!("项目 {index}");
|
||||
project.layers = json!({"largePayload": "x".repeat(160_000)});
|
||||
project.canvas.layers = json!({"largePayload": "x".repeat(160_000)});
|
||||
project
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let full = serde_json::to_vec(
|
||||
&projects
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(editor_project_payload_from_record)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.expect("完整项目列表应可序列化");
|
||||
let summaries = projects
|
||||
.into_iter()
|
||||
.map(external_editor_project_summary_from_record)
|
||||
.collect::<Vec<_>>();
|
||||
let summary = serde_json::to_vec(&summaries).expect("项目摘要列表应可序列化");
|
||||
|
||||
assert!(
|
||||
full.len() > MCP_LIMIT_BYTES,
|
||||
"测试数据必须先复现完整列表超过 MCP 上限"
|
||||
);
|
||||
assert_eq!(summaries.len(), 19);
|
||||
assert!(
|
||||
summary.len() < MCP_LIMIT_BYTES,
|
||||
"同一批项目的摘要必须低于 MCP 上限"
|
||||
);
|
||||
assert!(
|
||||
!summary
|
||||
.windows(b"largePayload".len())
|
||||
.any(|window| { window == b"largePayload" })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_editor_canvas_save_request_requires_expected_revision() {
|
||||
let missing_revision = serde_json::from_value::<ExternalEditorCanvasSaveRequest>(json!({
|
||||
|
||||
@@ -262,11 +262,16 @@ fn build_mcp_operations() -> Vec<McpOperation> {
|
||||
.or_else(|| operation.get("summary"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("调用陶泥儿外部编辑器 API");
|
||||
let path_template = if operation_id == "listEditorProjects" {
|
||||
format!("{path}?view=summary")
|
||||
} else {
|
||||
path.clone()
|
||||
};
|
||||
operations.push(McpOperation {
|
||||
tool_name: camel_to_snake(operation_id),
|
||||
operation_id: operation_id.to_string(),
|
||||
method,
|
||||
path_template: path.clone(),
|
||||
path_template,
|
||||
description: format!("{description}({} {path})", method_name.to_uppercase()),
|
||||
input_schema: Arc::new(build_operation_input_schema(
|
||||
&openapi,
|
||||
@@ -289,6 +294,8 @@ fn build_operation_input_schema(
|
||||
requires_idempotency_key: bool,
|
||||
) -> Map<String, Value> {
|
||||
let mut properties = Map::new();
|
||||
let fixes_project_list_to_summary =
|
||||
operation.get("operationId").and_then(Value::as_str) == Some("listEditorProjects");
|
||||
let parameters = path_item
|
||||
.get("parameters")
|
||||
.and_then(Value::as_array)
|
||||
@@ -314,6 +321,9 @@ fn build_operation_input_schema(
|
||||
let Some(name) = parameter.get("name").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if fixes_project_list_to_summary && location == "query" && name == "view" {
|
||||
continue;
|
||||
}
|
||||
parameter_properties.insert(
|
||||
name.to_string(),
|
||||
parameter
|
||||
@@ -447,6 +457,8 @@ async fn dispatch_operation(
|
||||
arguments: Map<String, Value>,
|
||||
context: &McpRequestContext<RoleServer>,
|
||||
) -> Result<Value, Value> {
|
||||
validate_required_body(operation, &arguments)?;
|
||||
|
||||
let parts = context
|
||||
.extensions
|
||||
.get::<axum::http::request::Parts>()
|
||||
@@ -479,28 +491,7 @@ async fn dispatch_operation(
|
||||
return Err(json!({"error": "缺少必填路径参数"}));
|
||||
}
|
||||
if let Some(query) = arguments.get("queryParameters").and_then(Value::as_object) {
|
||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
||||
for (name, value) in query {
|
||||
match value {
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
if let Some(value) = json_scalar_string(value) {
|
||||
serializer.append_pair(name, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
value => {
|
||||
if let Some(value) = json_scalar_string(value) {
|
||||
serializer.append_pair(name, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let query = serializer.finish();
|
||||
if !query.is_empty() {
|
||||
path.push('?');
|
||||
path.push_str(&query);
|
||||
}
|
||||
append_operation_query_parameters(operation, &mut path, query);
|
||||
}
|
||||
|
||||
let body = arguments.get("body").cloned().unwrap_or(Value::Null);
|
||||
@@ -566,6 +557,95 @@ async fn dispatch_operation(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_required_body(
|
||||
operation: &McpOperation,
|
||||
arguments: &Map<String, Value>,
|
||||
) -> Result<(), Value> {
|
||||
let body_is_required = operation
|
||||
.input_schema
|
||||
.get("required")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|required| required.iter().any(|name| name.as_str() == Some("body")));
|
||||
if !body_is_required {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let required_fields = operation.input_schema["properties"]["body"]
|
||||
.get("required")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let Some(body) = arguments.get("body").filter(|body| !body.is_null()) else {
|
||||
return Err(json!({
|
||||
"error": "缺少请求体",
|
||||
"requiredFields": required_fields,
|
||||
}));
|
||||
};
|
||||
|
||||
let Some(body) = body.as_object() else {
|
||||
return Err(json!({
|
||||
"error": "请求体必须是 JSON 对象",
|
||||
"expectedType": "object",
|
||||
}));
|
||||
};
|
||||
let missing_fields = required_fields
|
||||
.iter()
|
||||
.filter(|field| {
|
||||
field
|
||||
.as_str()
|
||||
.is_some_and(|field| !body.contains_key(field))
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if missing_fields.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(json!({
|
||||
"error": "缺少必填字段",
|
||||
"missingFields": missing_fields,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn append_query_parameters(path: &mut String, query: &Map<String, Value>) {
|
||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
||||
for (name, value) in query {
|
||||
match value {
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
if let Some(value) = json_scalar_string(value) {
|
||||
serializer.append_pair(name, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
value => {
|
||||
if let Some(value) = json_scalar_string(value) {
|
||||
serializer.append_pair(name, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let query = serializer.finish();
|
||||
if !query.is_empty() {
|
||||
path.push(if path.contains('?') { '&' } else { '?' });
|
||||
path.push_str(&query);
|
||||
}
|
||||
}
|
||||
|
||||
fn append_operation_query_parameters(
|
||||
operation: &McpOperation,
|
||||
path: &mut String,
|
||||
query: &Map<String, Value>,
|
||||
) {
|
||||
if operation.operation_id == "listEditorProjects" {
|
||||
let mut query = query.clone();
|
||||
query.remove("view");
|
||||
append_query_parameters(path, &query);
|
||||
} else {
|
||||
append_query_parameters(path, query);
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_external_api_success_payload(payload: Value) -> Value {
|
||||
payload
|
||||
.get("data")
|
||||
@@ -674,6 +754,127 @@ mod tests {
|
||||
assert_eq!(create_project.method, Method::POST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_list_tool_always_requests_summary_view() {
|
||||
let operation = MCP_OPERATIONS
|
||||
.iter()
|
||||
.find(|operation| operation.tool_name == "list_editor_projects")
|
||||
.expect("project list tool should exist");
|
||||
assert_eq!(
|
||||
operation.path_template,
|
||||
"/api/external/v1/editor/projects?view=summary"
|
||||
);
|
||||
assert!(
|
||||
operation.input_schema["properties"]
|
||||
.get("queryParameters")
|
||||
.is_none(),
|
||||
"MCP 项目列表固定 summary 后不得再向 Agent 暴露 REST view 参数"
|
||||
);
|
||||
|
||||
let mut path = operation.path_template.clone();
|
||||
append_operation_query_parameters(
|
||||
operation,
|
||||
&mut path,
|
||||
&Map::from_iter([
|
||||
("limit".to_string(), json!(20)),
|
||||
("view".to_string(), json!("full")),
|
||||
]),
|
||||
);
|
||||
assert_eq!(
|
||||
path,
|
||||
"/api/external/v1/editor/projects?view=summary&limit=20"
|
||||
);
|
||||
|
||||
let mut path = "/api/external/v1/editor/assets".to_string();
|
||||
append_query_parameters(
|
||||
&mut path,
|
||||
&Map::from_iter([("folderId".to_string(), json!("folder-1"))]),
|
||||
);
|
||||
assert_eq!(path, "/api/external/v1/editor/assets?folderId=folder-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_request_body_errors_are_derived_from_tool_schema() {
|
||||
for (tool_name, required_fields) in [
|
||||
(
|
||||
"confirm_external_asset_object",
|
||||
vec![json!("objectKey"), json!("assetKind")],
|
||||
),
|
||||
(
|
||||
"create_editor_asset",
|
||||
vec![
|
||||
json!("folderId"),
|
||||
json!("label"),
|
||||
json!("imageSrc"),
|
||||
json!("width"),
|
||||
json!("height"),
|
||||
json!("sourceType"),
|
||||
],
|
||||
),
|
||||
("create_editor_asset_folder", vec![json!("label")]),
|
||||
(
|
||||
"create_external_direct_upload_ticket",
|
||||
vec![json!("legacyPrefix"), json!("fileName")],
|
||||
),
|
||||
] {
|
||||
let operation = MCP_OPERATIONS
|
||||
.iter()
|
||||
.find(|operation| operation.tool_name == tool_name)
|
||||
.unwrap_or_else(|| panic!("{tool_name} should exist"));
|
||||
for arguments in [
|
||||
Map::new(),
|
||||
Map::from_iter([("body".to_string(), Value::Null)]),
|
||||
] {
|
||||
assert_eq!(
|
||||
validate_required_body(operation, &arguments),
|
||||
Err(json!({
|
||||
"error": "缺少请求体",
|
||||
"requiredFields": required_fields,
|
||||
})),
|
||||
"{tool_name}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
validate_required_body(
|
||||
operation,
|
||||
&Map::from_iter([("body".to_string(), json!({}))]),
|
||||
),
|
||||
Err(json!({
|
||||
"error": "缺少必填字段",
|
||||
"missingFields": required_fields,
|
||||
})),
|
||||
"{tool_name}"
|
||||
);
|
||||
assert_eq!(
|
||||
validate_required_body(
|
||||
operation,
|
||||
&Map::from_iter([("body".to_string(), json!([]))]),
|
||||
),
|
||||
Err(json!({
|
||||
"error": "请求体必须是 JSON 对象",
|
||||
"expectedType": "object",
|
||||
})),
|
||||
"{tool_name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_request_body_is_not_rejected() {
|
||||
let operation = MCP_OPERATIONS
|
||||
.iter()
|
||||
.find(|operation| operation.tool_name == "create_editor_project")
|
||||
.expect("project create tool should exist");
|
||||
assert_eq!(validate_required_body(operation, &Map::new()), Ok(()));
|
||||
assert_eq!(
|
||||
validate_required_body(
|
||||
operation,
|
||||
&Map::from_iter([("body".to_string(), Value::Null)]),
|
||||
),
|
||||
Ok(())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_tools_require_idempotency_key() {
|
||||
let operation = MCP_OPERATIONS
|
||||
|
||||
Reference in New Issue
Block a user