Compare commits

..

1 Commits

Author SHA1 Message Date
kdletters b79ee96f7e 补齐容器预览的 AGC Router 密钥说明
- deploy/container/api-server.env.example 增加 AGC 官方 LLM Router 段,列明容器预览必须由 .env.secrets.local 提供的三个密钥
- 【开发运维】Jenkins容器预览部署控制面技术方案 补充预览 secrets 必含密钥与缺失时的失败表现
- 【开发运维】本地开发验证与生产运维 补充容器与 Jenkins 预览路径同样受 Router 启动校验约束
- project-memory pitfalls 补充容器预览缺少 Router 密钥时的排障特征
2026-09-14 19:52:27 +08:00
9 changed files with 35 additions and 220 deletions
@@ -265,11 +265,11 @@ pub fn inspect_separation_recovery(
}
pub fn finalize_separation(root: &Path, asset_id: &str) -> Result<(), String> {
remove_separation_recovery_files(root, asset_id)
remove_separation_state(root, asset_id)
}
pub fn discard_separation_recovery(root: &Path, asset_id: &str) -> Result<(), String> {
remove_separation_recovery_files(root, asset_id)
remove_separation_state(root, asset_id)
}
fn remove_separation_state(root: &Path, asset_id: &str) -> Result<(), String> {
@@ -281,33 +281,6 @@ fn remove_separation_state(root: &Path, asset_id: &str) -> Result<(), String> {
}
}
fn remove_separation_recovery_files(root: &Path, asset_id: &str) -> Result<(), String> {
let sidecar = separation_sidecar_dir(root, asset_id)?;
remove_separation_state(root, asset_id)?;
let entries = match fs::read_dir(&sidecar) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(format!("读取 separation 临时文件失败:{error}")),
};
for entry in entries {
let entry = entry.map_err(|error| format!("读取 separation 临时文件失败:{error}"))?;
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with("processed-") || name.starts_with("binding-") {
let path = entry.path();
if entry
.file_type()
.map_err(|error| format!("检查 separation 临时文件失败:{error}"))?
.is_file()
{
fs::remove_file(&path)
.map_err(|error| format!("删除 separation 临时文件失败:{error}"))?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -4633,12 +4633,7 @@ export default function ProjectDevelopmentView({
passive: false,
});
return () => manager.removeEventListener('wheel', handleResourceBookWheel);
}, [
handleResourceBookWheel,
mode,
// UI 编辑器会卸载整个资源 manager;返回时 ref 指向新节点,必须重新绑定原生 wheel。
uiEditorRoute,
]);
}, [handleResourceBookWheel, mode]);
const handleResourceBookMainPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
@@ -1212,15 +1212,7 @@ export function useUiEditorSession(
'自动切分素材结果已写入编辑器,但 State 保存失败;sidecar 已保留,可继续恢复。',
);
}
if (
backfillErrors.length > 0 ||
completedResult.problematic_nodes.length > 0
) {
if (completedResult.problematic_nodes.length > 0) {
backfillErrors.push(
`${completedResult.problematic_nodes.length} 个节点达到返工上限,需要人工处理`,
);
}
if (backfillErrors.length > 0) {
const recovery = await invoke<SeparationRecoveryDTO>(
'inspect_separation_recovery',
{ projectPath, assetId: resourceId },
@@ -1227,32 +1227,6 @@ async function openMainProject(projectPath: string) {
).not.toBeNull();
}
export function installResizeObserverStub() {
let observerCount = 0;
let observerDisconnected = false;
class TestResizeObserver {
constructor(readonly callback: ResizeObserverCallback) {
observerCount += 1;
}
observe() {}
unobserve() {}
disconnect() {
observerDisconnected = true;
}
}
Object.defineProperty(window, 'ResizeObserver', {
configurable: true,
value: TestResizeObserver,
});
return {
observerCount: () => observerCount,
observerDisconnected: () => observerDisconnected,
};
}
beforeEach(() => {
resetLlmModelCatalogCacheForTest();
vi.spyOn(clientApi, 'loadClientLlmModels').mockResolvedValue({
@@ -34,7 +34,6 @@ import {
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
type GameCreationAgentRunTrace,
getResourceSelectButton,
installResizeObserverStub,
it,
mockRoleAgentReply,
openResourceFilterPanel,
@@ -4709,7 +4708,25 @@ export function registerProjectWorkbenchFoundationTests() {
manuallyPlaced: false,
},
];
const resizeObserver = installResizeObserverStub();
let observerCount = 0;
let observerDisconnected = false;
class TestResizeObserver {
constructor(readonly callback: ResizeObserverCallback) {
observerCount += 1;
}
observe() {}
unobserve() {}
disconnect() {
observerDisconnected = true;
}
}
Object.defineProperty(window, 'ResizeObserver', {
configurable: true,
value: TestResizeObserver,
});
const animationFrame = vi.spyOn(window, 'requestAnimationFrame');
const originalGetBoundingClientRect =
HTMLElement.prototype.getBoundingClientRect;
@@ -4801,7 +4818,7 @@ export function registerProjectWorkbenchFoundationTests() {
/^url\(#.+-asset-reference-arrow\)$/u,
);
});
expect(resizeObserver.observerCount()).toBe(1);
expect(observerCount).toBe(1);
expect(
overlay
.querySelector('[data-testid="resource-dependency-overlay-scene"]')
@@ -4848,7 +4865,7 @@ export function registerProjectWorkbenchFoundationTests() {
),
);
rendered.unmount();
expect(resizeObserver.observerDisconnected()).toBe(true);
expect(observerDisconnected).toBe(true);
expect(removeViewportListener).toHaveBeenCalledWith(
'scroll',
expect.any(Function),
@@ -5899,144 +5916,6 @@ export function registerProjectWorkbenchFoundationTests() {
);
});
it('restores resource canvas panning after returning from the UI editor', async () => {
installResizeObserverStub();
const manifest = createGameCreationAppManifest(
'workbench-ui-editor-return-pan',
'UI 编辑器返回平移测试',
);
manifest.assets = [
{
id: 'ui-design-resource',
kind: 'UI',
mediaType: 'application/json',
localPath: 'assets/ui-design.json',
source: { kind: 'generated' },
},
];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
if (command === 'load_ui_design_state') {
return {
revision: 0,
state: {
ui_trees: [],
ui_design_images: {},
sprite_assets: {},
font_assets: {},
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-ui-editor-return-pan',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('UI 交互');
const canvas = await screen.findByRole('region', { name: 'UI 交互' });
const scene = document.querySelector<HTMLElement>(
'[data-resource-book-view="child"] .game-resource-book-scene',
);
const wheel = (target: HTMLElement, deltaY: number) => {
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY,
clientX: 120,
clientY: 100,
});
act(() => target.dispatchEvent(event));
expect(event.defaultPrevented).toBe(true);
};
wheel(scene ?? canvas, 120);
const world = canvas.querySelector<HTMLElement>('[data-resource-viewport]');
expect(world?.getAttribute('data-resource-viewport')).toMatch(
/^(?!48,48,1)/u,
);
fireEvent.click(
screen.getByRole('button', {
name: '选中资源:UI 交互 ui-design.json',
}),
);
fireEvent.click(await screen.findByRole('button', { name: 'UI 编辑器' }));
await screen.findByRole('button', { name: '返回资源' });
fireEvent.click(screen.getByRole('button', { name: '返回资源' }));
await waitFor(() =>
expect(screen.queryByRole('button', { name: '返回资源' })).toBeNull(),
);
const restoredCanvas = await screen.findByRole('region', {
name: 'UI 交互',
});
const restoredWorld = restoredCanvas.querySelector<HTMLElement>(
'[data-resource-viewport]',
);
const beforeReturn = world?.getAttribute('data-resource-viewport');
const restoredBeforePan = restoredWorld?.getAttribute(
'data-resource-viewport',
);
expect(restoredBeforePan).toBe(beforeReturn);
const restoredScene = document.querySelector<HTMLElement>(
'[data-resource-book-view="child"] .game-resource-book-scene',
);
const restoredWheelEvent = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 120,
clientX: 120,
clientY: 100,
});
act(() =>
(restoredScene ?? restoredCanvas).dispatchEvent(restoredWheelEvent),
);
expect(restoredWheelEvent.defaultPrevented).toBe(true);
expect(restoredWorld?.getAttribute('data-resource-viewport')).not.toBe(
restoredBeforePan,
);
});
it('clears the resource preview cache and cancels the old preview scope on project switch', async () => {
// 60d8b8fbb 删掉了从未被写入的 `resourcePreviewVersionByResourceId` 死接线,原先钉
// 那条 prop 与它的清空语句的断言随之取消;用例真正要守的意图不变——项目切换必须把
+6
View File
@@ -65,5 +65,11 @@ GENARRATIVE_LLM_PROVIDER=openai-compatible
GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
# AGC 官方 LLM Routerapi / all 角色启动时硬校验官方 HTTPS 地址、固定模型和以下三个密钥,缺失直接拒绝启动。
# 容器预览的密钥来自镜像内 /srv/genarrative/.env.secrets.localJenkins 预览 secrets 副本),
# 该副本必须包含 GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET、
# GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET 和 GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN。
# 本文件只用于按实例显式覆盖;容器环境变量留空时仍由镜像内 .env.secrets.local 提供取值。
# GENARRATIVE_LLM_ROUTER_BASE_URL=https://router.genarrative.world/v1
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=
@@ -1,13 +1,5 @@
# 踩坑与排障记录
## 2026-09-14 UI 编辑器返回后资源画布滚轮平移失效
- **现象**:资源管理打开 UI 编辑器再返回后,资源画布滚轮平移/缩放不再响应;返回前同一手势正常。
- **原因**:资源画布的非 passive `wheel` 监听绑定在 `resourceBookManagerRef` 当前 DOM 上,但 effect 只依赖 `handleResourceBookWheel``mode`。UI 编辑器切换会卸载旧 manager 并挂载新 manager,依赖不变导致新节点没有重新绑定监听。
- **处理**:将 `uiEditorRoute` 纳入 wheel effect 依赖,使进入/退出 UI 编辑器时先清理旧节点监听,再给返回后的新 manager 绑定同一处理器。
- **验证**`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts -t "restores resource canvas panning"`;回归用例覆盖打开栏目、wheel 平移、进入 UI 编辑器、返回并再次 wheel 平移。
- **关联**`apps/ai-game-creator-shell/src/view/project-development/index.tsx``apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`
## 2026-09-14 AGC 壳 Rust 套件按「一片一 job」拆分,且分片必须自校验覆盖
- **现象**`AI game creator shell Rust tests` 一直是客户端 CI 的关键路径。run 2097 实测 15 分 27 秒,其中 `apps/ai-game-creator-shell/src-tauri` 的 bin target 单测(2466 条)一条 `cargo test -- --test-threads=1` 串行占 507 秒。
@@ -5205,6 +5197,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- 外层 `start-tauri-dev.mjs` 应在启动 Tauri 前完成配套开发服务准备,并统一收束自有服务进程树;不要让冷编译和数据库发布挤占 Tauri 的前端就绪等待。自动发布必须保留数据库,不能靠清库解决启动问题。
- CLI 与 standalone 可能是两个独立软链接。必须同时核对 `spacetime --version``spacetimedb-standalone --version`,不能把 CLI 的版本记录当作宿主版本证明;PATH 中存在宿主时启动器检查两者一致。更换宿主前停机备份数据,按原目录启动,不通过清库处理版本错配。
- Router 配置缺失不应只在首次请求时报错。API/All 启动必须先校验官方地址、固定模型、provisioning secret、管理员 Token 和凭据加密密钥;否则服务看似 healthy,但登录后的 provisioning/模型调用才延迟失败。
- 容器预览没有独立的 Router 配置来源,密钥只能来自 Jenkins 预览 secrets 副本内置的 `.env.secrets.local`。新增启动期密钥校验时必须同步维护该副本并重建预览镜像,否则表现为 api-server 退出码 1、nginx 停在 `Created`、worker 反复报“模型定价服务身份尚未初始化”,而 systemd 生产路径仍然正常,容易误判成容器或数据库问题。
## 共享画布框选需要识别 world 的真实后代命中
@@ -55,6 +55,8 @@ Jenkins 节点上的预览 `.env.local` 与 secrets 只允许来自受控的 Jen
宿主固定目录应由 Jenkins 运行账号所有且权限为 `0700`,两个源文件权限均为 `0600`;缺失、不是普通文件、owner 不匹配或权限过宽时,预览构建必须失败关闭。任一源文件变更后必须重新构建并替换 API 与 worker 预览镜像,只重启容器不会刷新已内置的内容。容器启动时显式注入的运行环境变量优先级高于镜像内的 `.env.local``.env.secrets.local`,用于按实例覆盖非通用值。
预览 secrets 副本还必须包含 AGC 官方 Router 的三个密钥:`GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET``GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET``GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN`。容器 `api` / `all` 角色启动时会硬校验官方 HTTPS 地址、固定模型和这三个密钥,缺失时 api-server 立即以退出码 1 结束,`nginx` 停在 `Created``external-generation-worker` 因模型定价服务身份未初始化而反复重启。该失败发生在镜像构建和模块发布之后,因此新增任何启动期密钥校验时必须同步维护预览 secrets 副本并重建镜像,不能等预览页面报错再排查。
这种方案只隐藏构建传输过程,不能让内置后的 secrets 对镜像持有者保密:能读取、保存或运行 `api-runtime` 镜像的人可以提取该文件。因此该镜像只能留在当前受信任内网 Docker 主机,禁止 push 到公共或跨信任边界的 registry,也禁止通过 `docker save`/构建 artifact 导出传播。需要跨边界分发时必须改用不含 secrets 的镜像与运行时密钥注入。
## Jenkins 参数与产物
@@ -969,3 +969,4 @@ node scripts/rebind-orphan-work-owners.mjs --in <exported-migration.json> --out
`scripts/deploy/maintenance-on.sh` 只允许把同目录临时普通文件原子替换到普通文件或尚不存在的 `page.html` / `enabled` 目标。目标只要是符号链接(包括指向目录的链接)或目录,脚本必须在替换前失败,不能跟随链接把临时文件移入链接目标,也不能打印“已进入维护模式”。`page_temp``marker_temp` 必须在 `set -u` 下安全初始化,清理 trap 必须在首次 `mktemp` 前生效;任一失败退出都不得在目标同级遗留 `page.html.tmp.*``enabled.tmp.*`,成功替换后则清空临时路径并解除 trap,不能误删已安装目标。跨平台实现继续使用 POSIX `mv -f`,安全语义由替换函数的目标类型门禁保证;修改后运行 `bash -n scripts/deploy/maintenance-on.sh``npm run check:maintenance-page`
api-server 启动时会硬校验 AGC 官方 Router 配置:API/All 角色必须使用官方 HTTPS 地址和固定模型,并配置 provisioning secret、Router 管理员 Token,以及专用加密 secret 或有效 JWT secret;缺失或不匹配直接拒绝启动。`test` 环境仅允许 loopback fixtureworker-only 角色不执行 Router 配置校验。
容器与 Jenkins 预览路径同样受该校验约束,且密钥只能来自预览 secrets 副本内置的 `.env.secrets.local`:缺少上述三个密钥时,预览的 api-server 容器启动即退出,`nginx``external-generation-worker` 连带失败。维护预览环境时必须先补齐这三个键并重建镜像。