diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 848b74692..b159be8a2 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -645,17 +645,19 @@ test('Windows remains the default and explicit Windows overrides macOS environme test('no-bundle smoke skips version writes and manifest generation', async () => { const steps = []; - await buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], { - prepareVersion: () => { - steps.push('version'); - }, - build: (_args, context) => { - steps.push(context.channel); - }, - generateManifest: () => { - steps.push('manifest'); - }, - }); + await withEnv({ AGC_UPDATE_CHANNEL: undefined }, () => + buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], { + prepareVersion: () => { + steps.push('version'); + }, + build: (_args, context) => { + steps.push(context.channel); + }, + generateManifest: () => { + steps.push('manifest'); + }, + }), + ); assert.deepEqual(steps, ['dev']); }); diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index fb1a1d91b..967bf3eb5 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1941,6 +1941,7 @@ for (const snippet of [ '官方账号服务(固定)', 'runtime_config.save', "'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'", + '已载入客户端运行视图', 'async function executeRunLocal', 'function needsInitializedChatProject', "'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'", diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 954d67987..7884f201f 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -184,6 +184,7 @@ type AppProps = { metadata?: ProjectManifestSnapshotMetadata, ) => void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; + onRunNotice?: ProjectChatComponentProps['onRunNotice']; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], ) => void; @@ -216,6 +217,7 @@ export function App({ onPlayRequestHandled, onManifestChange, onPreviewChange, + onRunNotice, onAgentRuntimeSummariesChange, onAgentResultsChange, }: AppProps = {}) { @@ -812,9 +814,7 @@ export function App({ const agentRuntimeResumeProjectPathRef = useRef(null); const initialProjectOpenedRef = useRef(false); const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null); - const executeRunLocalRef = useRef<(announceToChat: boolean) => void>( - () => undefined, - ); + const executeRunLocalRef = useRef<() => void>(() => undefined); const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false); function requestRuntimeConfigOpen() { @@ -1388,7 +1388,7 @@ export function App({ } handledPlayRequestRef.current = requestKey; onPlayRequestHandled?.(playRequest.requestId); - void executeRunLocalRef.current(true); + void executeRunLocalRef.current(); }, [localProject?.projectPath, onPlayRequestHandled, playRequest]); /** @@ -1720,20 +1720,6 @@ export function App({ } } - /** - * 工作台壳要把一句结果说给用户在项目对话里听。 - * - * DirectProject 的会话由聊天容器持有,壳只把这句话交给聊天的本地消息流; - * 立项策划路径仍写壳自己的 `messages`。 - */ - function announceProjectChatMessage(text: string) { - if (directProjectMode) { - directProjectChatRef.current?.announce(text); - return; - } - setMessages((current) => [...current, { role: 'assistant', text }]); - } - async function executeChatAgentReply({ prompt, clientTurnId: directConversationTurnId, @@ -1839,19 +1825,18 @@ export function App({ } } - async function executeRunLocal(announceToChat: boolean) { + async function executeRunLocal() { const invoke = resolveTauriInvoke(); if (!invoke) { - if (announceToChat) { - announceProjectChatMessage('需要在 Tauri App 内运行。'); - } + onRunNotice?.({ tone: 'error', message: '需要在 Tauri App 内运行。' }); return; } const nextProjectPath = resolveChatProjectPath(localProject); if (!nextProjectPath) { - if (announceToChat) { - announceProjectChatMessage('请先用 /project 设置本地项目。'); - } + onRunNotice?.({ + tone: 'error', + message: '请先用 /project 设置本地项目。', + }); return; } @@ -1862,11 +1847,9 @@ export function App({ ); if (activePreview) { updateClientPreview(activePreview); - if (announceToChat) { - announceProjectChatMessage( - `已切换到客户端运行视图:${activePreview.url}`, - ); - } + // 运行成功的反馈走工作台壳的 toast(对话区只保留对话内容),因此不再往聊天里 + // 写一条「已切换到客户端运行视图:URL」。 + onRunNotice?.({ message: '已载入客户端运行视图' }); return; } const previewResult = await invoke( @@ -1878,16 +1861,19 @@ export function App({ if (!directProjectMode) { void refreshAgentRunTrace(nextProjectPath); } - if (announceToChat) { - announceProjectChatMessage( - `运行通过,已载入客户端运行视图:${previewResult.url}`, - ); - } + /* + * 成功与失败都走工作台壳的 toast,对话区不再承载这条过程反馈,所以这两处不受 + * `announceToChat` 约束(它是旧的「聊天播报」开关)。当前唯一调用点由播放请求驱动、 + * 恒为 true(见 `executeRunLocalRef.current(true)`)。 + */ + onRunNotice?.({ message: '运行通过,已载入客户端运行视图' }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (announceToChat) { - announceProjectChatMessage(message); - } + onRunNotice?.({ + tone: 'error', + message: `运行游戏失败:${ + error instanceof Error ? error.message : String(error) + }`, + }); } } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx b/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx new file mode 100644 index 000000000..85e4cc298 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx @@ -0,0 +1,70 @@ +import { PlatformRuntimeStatusToast } from '@genarrative/shared/components'; +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; + +import type { ProjectRunNotice } from './model'; + +/** + * 成功提示一闪而过就够;失败提示要留得够久,用户得看清是什么没跑起来。 + * + * 「运行 / 预览失败」这类错误已经不再写对话区(那里只保留对话内容),所以这枚 toast 是它 + * 唯一的出口——2.6 秒对错误太短。 + */ +const RUN_NOTICE_MILLIS: Record<'success' | 'error', number> = { + success: 2600, + error: 6000, +}; + +export type RunNotice = ProjectRunNotice & { + /** 每次提示自增,保证重复触发同一个文案时也会重新弹一次。 */ + id: number; +}; + +/** + * 运行 / 预览类动作的浮层提示。 + * + * 这类过程反馈以前以 assistant 消息写进对话区,会一直堆在对话底部挡住运行画面; + * 现在统一走 toast,对话区只保留对话内容——运行页的预览地址改用顶栏的「在浏览器打开」。 + */ +export function RunNoticeToast({ + notice, + onDismiss, +}: { + notice: RunNotice | null; + onDismiss: () => void; +}) { + useEffect(() => { + if (!notice) { + return; + } + const timer = window.setTimeout( + onDismiss, + RUN_NOTICE_MILLIS[notice.tone ?? 'success'], + ); + return () => { + window.clearTimeout(timer); + }; + }, [notice, onDismiss]); + + if (!notice) { + return null; + } + return createPortal( +
+ + {notice.message} + +
, + document.body, + ); +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index d9c3e4a62..809e9b88c 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -42,8 +42,9 @@ import { projectPathsMatchForInvalidation } from '../project-summary/projectPath import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog'; import { useTemplateLibrary } from '../template-library/useTemplateLibrary'; import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet'; -import type { WorkspaceLauncherShellProps } from './model'; +import type { ProjectRunNotice, WorkspaceLauncherShellProps } from './model'; import { NonEmptyProjectDialog, ProjectsPage } from './ProjectCreation'; +import { type RunNotice, RunNoticeToast } from './RunNoticeToast'; import { useAccountWallet } from './useAccountWallet'; import { DESIGN_ARTIFACTS_BUILD_PROMPT, @@ -79,6 +80,13 @@ export function WorkspaceLauncherShell({ title: string; message: string; } | null>(null); + /** + * 运行 / 预览类动作的浮层提示。 + * + * 这类过程反馈不进对话区(见 `ProjectChatComponentProps.onRunNotice`), + * `id` 每次自增,保证同一句文案连续触发时也会重新弹一次。 + */ + const [runNotice, setRunNotice] = useState(null); // 「做成游戏」切换记录按项目上下文(路径 + createdAt)定位。运行模式必须随 // currentProjectContext 同步派生,不能靠 effect 后置修正:首帧挂错 lane 会先 // 以游戏运行时挂载并消耗首轮 claim,重挂后的策划实例再也发不出首轮。 @@ -600,6 +608,10 @@ export function WorkspaceLauncherShell({ ); }, []); + const handleRunNotice = useCallback((notice: ProjectRunNotice) => { + setRunNotice((current) => ({ id: (current?.id ?? 0) + 1, ...notice })); + }, []); + function showLauncherNotice(title: string) { setLauncherNotice({ title, @@ -825,6 +837,7 @@ export function WorkspaceLauncherShell({ currentProjectContext.projectPath, ) } + onNotice={handleRunNotice} onManifestChange={syncActiveProjectManifest} onHomeOpen={() => setLauncherView('home')} onProjectsOpen={() => setLauncherView('projects')} @@ -862,6 +875,7 @@ export function WorkspaceLauncherShell({ onPlayRequestHandled={handlePlayRequestHandled} onManifestChange={syncActiveProjectManifest} onPreviewChange={setActiveProjectPreview} + onRunNotice={handleRunNotice} onAgentRuntimeSummariesChange={ setActiveProjectAgentRuntimeSummaries } @@ -905,6 +919,7 @@ export function WorkspaceLauncherShell({ ) : isWindowChrome ? null : ( )} + setRunNotice(null)} /> {launcherNotice ? (
void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; + /** + * 运行 / 预览类动作的一次性浮层提示。 + * + * 「跑起来了」「已切到运行视图」「在浏览器打开失败」属于过程反馈,不进对话区—— + * 对话区只保留对话内容。工作台壳收到后弹 toast,`tone` 决定成功还是失败观感。 + */ + onRunNotice?: (notice: ProjectRunNotice) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], ) => void; diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx index 65a739217..bc5d79f0f 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx @@ -75,7 +75,14 @@ export function GameRunVersionPicker({ aria-label={`当前版本:${formatIterationVersionLabel(currentVersion)}`} onClick={() => setOpen((current) => !current)} > - {formatIterationVersionLabel(currentVersion)} + {/* + 版本名可能很长(`初始版本 · 2026/9/19 02:10:03`)。按钮是 flex 容器,直接放文本节点 + 时 `text-overflow: ellipsis` 不生效(匿名 flex item 不参与父级省略),所以套一层 + span 由它省略(见样式里的 `.game-run-version-trigger-label`)。 + */} + + {formatIterationVersionLabel(currentVersion)} + {open ? createPortal( diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx index f526ea984..3399d5376 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx @@ -56,7 +56,7 @@ export type ResourceCanvasAssetGenerationTasksPanelViewProps = { * * 两页之间切换时锚点高度走样式里的 transition,不瞬移。 */ - placement?: 'canvas-overview' | 'canvas' | 'run' | 'editor'; + placement?: 'canvas-overview' | 'canvas' | 'editor'; }; /** diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css index fc6cbf3d3..cb53b2c14 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css @@ -23,8 +23,9 @@ * 偏移量迟早会压在工具条上(验收现场那条「生成任务和菜单栏重叠」就是这么来的)。 * 放进同一个网格单元格以后,「画布顶边 + 一小段内缩」由网格自己保证,工具条多高都不用管。 * - * 分档:资源栏目画布与 UI 编辑器在第二 / 第三行,运行表现层占 `2 / -1` 且它右上角 - * (`top: 20px; right: 20px`)被版本入口占着,所以那一档把内缩上边距加大到版本入口之下。 + * 分档:资源栏目画布与 UI 编辑器各一档(第二 / 第三行)。**运行表现层没有这一档**—— + * 运行页不挂这个锚点(见 `project-development/index.tsx`),画面右上角留给版本入口与 + * 预览地址小字。 */ .game-resource-generation-tasks-anchor { grid-row: 3; @@ -67,11 +68,6 @@ margin-top: calc(2.625rem + 0.5rem); } -.game-resource-generation-tasks-anchor[data-generation-tasks-placement='run'] { - grid-row: 2 / -1; - margin: 3.5rem 1.1rem 0.85rem 0.85rem; -} - .game-resource-generation-tasks-anchor[data-generation-tasks-placement='editor'] { grid-row: 2; } diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 3ef53ec8f..56345e650 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -8468,8 +8468,8 @@ iframe.preview-frame { display: grid; grid-template-rows: minmax(300px, 1fr) auto; grid-row: 2 / -1; - /* 与右上角任务锚点同格(见 resourceCanvasAssetGenerationTasksSidebar.css):显式钉第 1 列, - 避免画布被自动列放置挤到隐式列里。 */ + /* 显式钉第 1 列(见 resourceCanvasAssetGenerationTasksSidebar.css),避免画面被自动列放置 + 挤到隐式列里。 */ grid-column: 1; gap: 12px; height: 100%; @@ -8478,39 +8478,35 @@ iframe.preview-frame { background: transparent; } -/* C7 版本入口:运行模块右上角,没有版本时不渲染。 */ +/* + * C7 版本入口:住在工作台顶栏动作区(`.game-workbench-view-actions`),外观由那一组的基础 + * 规则给(描边 + secondary 填充 + 12px/700),这里只补「版本名可能很长」这一件事。 + * + * 它曾经是运行画面里的绝对定位浮层(top 20px / right 20px):压在游戏画面上,还与顶栏其他 + * 按钮分成两套皮。**不要再给它加 border / background / color / hover**——那就是第二套皮。 + */ .game-run-version-picker { - position: absolute; - top: 20px; - right: 20px; - z-index: 5; + flex: 0 0 auto; + display: inline-flex; + align-items: center; } .game-run-version-trigger { - display: inline-flex; max-width: min(18rem, 60vw); - min-height: 30px; - align-items: center; - padding: 0 12px; - border: 1px solid #e5cfc4; - border-radius: 999px; - background: rgb(255 250 246 / 92%); - color: #8a4a30; - cursor: pointer; - font-size: 12px; - font-weight: 700; + min-width: 0; +} + +/* + * 版本名单独一层才省得掉:按钮是 flex 容器,文本直接挂在按钮上时 `text-overflow` + * 落在匿名 flex item 上、不生效(见 `GameRunVersionPicker` 里的注释)。 + */ +.game-run-version-trigger-label { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.game-run-version-trigger:hover, -.game-run-version-trigger:focus-visible { - border-color: #cc8060; - outline: 0; - box-shadow: 0 3px 10px rgb(158 87 57 / 14%); -} - .game-run-version-menu { display: grid; gap: 2px; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index edf2bc0d5..347703b5d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -16,10 +16,12 @@ import { } from '@genarrative/image-canvas-react'; import { CanvasCardCornerActions } from '@genarrative/shared/components'; import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; +import { openUrl } from '@tauri-apps/plugin-opener'; import { AtSign, Box, Crosshair, + ExternalLink, Eye, FileCode2, FileText, @@ -104,6 +106,7 @@ import { isFloatingOverlayWheelEvent, useImageCanvasFloatingOptionDismiss, } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; +import type { ProjectRunNotice } from '../../features/app-shell/model'; import { DesignWorkspacePanel } from '../../features/project-workspace/DesignWorkspacePanel'; import { LocalGamePreviewFrame, @@ -790,6 +793,13 @@ export type ProjectDevelopmentViewProps = { onPlay?: () => void; onMakeGame?: () => void; onRevealProjectDirectory?: () => void | Promise; + /** + * 过程提示(成功 / 失败)的唯一出口。 + * + * 「运行起来了」「在浏览器打开失败」这类反馈不进对话区,交给工作台壳弹 toast; + * 本视图不自己造第二套提示位。 + */ + onNotice?: (notice: ProjectRunNotice) => void; onManifestChange?: ( projectPath: string, manifest: GameCreationAppManifest, @@ -1795,6 +1805,7 @@ export default function ProjectDevelopmentView({ onPlay, onMakeGame, onRevealProjectDirectory, + onNotice, }: ProjectDevelopmentViewProps) { const [mode, setMode] = useState('resources'); const [runtimeInspectMode, setRuntimeInspectMode] = useState(false); @@ -2709,6 +2720,33 @@ export default function ProjectDevelopmentView({ const preview = previewOverride ?? manifest.preview ?? null; const embeddedPreviewUrl = resolveEmbeddedPreviewUrl(preview); + /** + * 「在浏览器打开」:把当前预览地址交给系统默认浏览器。 + * + * 这是**用户主动**动作。预览的启动与切换仍然只走内置运行画面、不自动开外部浏览器 + * (那条边界由 `scripts/check-native-shells.mjs` 的守卫钉着);失败时把原因交给 + * `onNotice` 弹 toast,不在工具条里另造一条提示位、也不写进对话区。 + */ + const openPreviewInBrowser = useCallback(async () => { + if (!embeddedPreviewUrl) { + return; + } + try { + await openUrl(embeddedPreviewUrl); + } catch (error) { + // 没有提示通道时(测试挂载、未来宿主)至少留下排查痕迹,不静默吞掉。 + if (!onNotice) { + console.error('[agc] 在浏览器打开失败', error); + return; + } + onNotice({ + tone: 'error', + message: `在浏览器打开失败:${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + }, [embeddedPreviewUrl, onNotice]); const runAvailable = embeddedPreviewUrl !== null || manifest.tasks.some( @@ -9645,6 +9683,22 @@ export default function ProjectDevelopmentView({ {runtimeInspectMode ? '退出点选' : '点选素材'} ) : null} + {/* + 当前预览地址的出口。以前这里是一条「已载入客户端运行视图:http://…」小字, + 长地址既占位又不可点:地址本身对用户没用,能一步跳浏览器才有用。 + 只在有活预览时出现,外观与其他动作按钮同款(见 `.game-workbench-view-actions button`)。 + */} + {mode === 'run' && embeddedPreviewUrl ? ( + + ) : null} {mode === 'resources' && !uiEditorRoute ? ( <>
{/* 布局状态提示**不进动作行**:它是一段随保存过程变长的文案(空 →「保存中」→ @@ -10889,11 +10958,6 @@ export default function ProjectDevelopmentView({ ) : (
-
{embeddedPreviewUrl ? ( )} {/* - 「生成任务」侧栏:常驻在**工作面右上角**(资源画布 / 运行表现层 / UI 编辑器各自一档坐标), - 可折叠、非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen` 的模态遮挡判据—— - 生成在后台跑,侧栏展开时画布必须照样能看能用;折叠只影响这个视图,任务本身活在账本 - 与本地队列里。位置与开合形态照抄美术画布(开关常驻右上角 + 面板贴着它展开), - 颜色与外形仍走 AGC 的平台 token。 + 「生成任务」侧栏:常驻在**资源画布 / UI 编辑器**的右上角(各一档坐标),可折叠、非模态。 + 它**不**参与 `isResourceCanvasFloatingPanelOpen` 的模态遮挡判据——生成在后台跑, + 侧栏展开时画布必须照样能看能用;折叠只影响这个视图,任务本身活在账本与本地队列里。 + + **运行表现层不挂它**:运行页要留给游戏画面,右上角是版本入口与「在浏览器打开」, + 再叠一枚任务开关(或展开的面板)就会压在画面上。任务不会因此丢,切回资源页即可见。 */} - task.projectId === manifest.projectId, - )} - resourceEditTasks={resourceCanvasResourceEdits.filter( - (task) => - !task.restored || - !resourceCanvasResourceEditTaskIsTerminal(task), - )} - open={resourceAssetGenerationTasksPanelOpen} - onToggleOpen={() => - setResourceAssetGenerationTasksPanelOpen((current) => !current) - } - onFocusTask={focusResourceAssetGenerationTask} - /* - 资源总览页与资源栏目画布页的顶部 chrome 不同:画布页钉着整宽的栏目标题栏 - (含「返回资源总览」),总览页没有。两页因此各有一档坐标(见样式里的 - `[data-generation-tasks-placement]`),切换时走锚点高度过渡。 - */ - placement={ - uiEditorRoute - ? 'editor' - : mode === 'run' - ? 'run' + {mode === 'run' ? null : ( + task.projectId === manifest.projectId, + )} + resourceEditTasks={resourceCanvasResourceEdits.filter( + (task) => + !task.restored || + !resourceCanvasResourceEditTaskIsTerminal(task), + )} + open={resourceAssetGenerationTasksPanelOpen} + onToggleOpen={() => + setResourceAssetGenerationTasksPanelOpen((current) => !current) + } + onFocusTask={focusResourceAssetGenerationTask} + /* + 资源总览页与资源栏目画布页的顶部 chrome 不同:画布页钉着整宽的栏目标题栏 + (含「返回资源总览」),总览页没有。两页因此各有一档坐标(见样式里的 + `[data-generation-tasks-placement]`),切换时走锚点高度过渡。 + */ + placement={ + uiEditorRoute + ? 'editor' : resourceBookView === 'child' ? 'canvas' : 'canvas-overview' - } - /> + } + /> + )}
{!uiEditorRoute ? ( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 119b1463f..1d30225a8 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -8821,7 +8821,8 @@ export function registerProjectAgentStatusTests() { ); expect(within(reopened).getByText('待提交设计图')).not.toBeNull(); - // 入口在「运行」页签下同样常驻:侧栏本体在运行态可见,入口若只在资源页签就没法再打开。 + // 运行页签下**不挂**「生成任务」:那一页要留给游戏画面(顶栏是版本入口与「在浏览器打开」)。 + // 任务不会因此丢——切回资源页签,入口与面板都回来。 fireEvent.click( within(reopened).getByRole('button', { name: '关闭生成任务' }), ); @@ -8831,8 +8832,16 @@ export function registerProjectAgentStatusTests() { expect( screen.getByRole('tab', { name: '运行' }).getAttribute('aria-selected'), ).toBe('true'); + expect( + screen.queryByRole('button', { name: /^生成任务(?: · \d+)?$/ }), + ).toBeNull(); + expect( + document.querySelector('.game-resource-generation-tasks-anchor'), + ).toBeNull(); + + fireEvent.click(screen.getByRole('tab', { name: '资源管理' })); fireEvent.click( - screen.getByRole('button', { name: /^生成任务(?: · \d+)?$/ }), + await screen.findByRole('button', { name: /^生成任务(?: · \d+)?$/ }), ); expect( await screen.findByRole('region', { name: '生成任务' }), diff --git a/apps/ai-game-creator-shell/tests/gameRunToolbarActionsStyle.test.ts b/apps/ai-game-creator-shell/tests/gameRunToolbarActionsStyle.test.ts new file mode 100644 index 000000000..85cdf0265 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/gameRunToolbarActionsStyle.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs'; + +import { describe, expect, test } from 'vitest'; + +import { repoPath } from './repoPath'; +import { + declaration, + parseStyleSheet, + resolveDeclarations, +} from './styleCascade'; + +/** + * 运行页顶栏动作区的声明级断言。 + * + * jsdom 不加载全局样式表,所以这里按仓库既有做法(`styleCascade`)解析真实生效的声明: + * 「版本入口与「在浏览器打开」跟其他动作按钮同一套皮」只由样式决定,用例必须钉在声明上, + * 否则下一次改动给版本入口单开一套 border / background / hover 时没有任何东西会红。 + */ +const GLOBAL_CSS_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css'); +const rules = parseStyleSheet(readFileSync(GLOBAL_CSS_PATH, 'utf8')); +// 同一个求值器限制:全局表里也有 `prefers-reduced-motion` 档,求值时先排除掉。 +const widthRules = rules.filter( + (rule) => !(rule.media ?? '').includes('prefers-reduced-motion'), +); + +function resolved(selectors: readonly string[]): Map { + return resolveDeclarations(widthRules, selectors, 1280); +} + +function hasRule(selector: string): boolean { + return widthRules.some((rule) => rule.selectors.includes(selector)); +} + +describe('运行页顶栏动作区样式', () => { + test('运行画面回到两行,页面里不再有预览地址状态行', () => { + const surface = resolved(['.game-run-surface']); + expect(declaration(surface, 'grid-template-rows')).toBe( + 'minmax(300px, 1fr) auto', + ); + // 状态行与那行小字整体退役:地址不再以文字常驻在页面上。 + expect(hasRule('.game-run-status-bar')).toBe(false); + expect(hasRule('.game-run-status-hint')).toBe(false); + }); + + test('版本入口住进顶栏动作区,皮由那一组的基础规则给', () => { + // 动作区的按钮共用一套基础外观(这一组是「在浏览器打开」与版本入口的共同来源)。 + const actionsButton = resolved(['.game-workbench-view-actions button']); + expect(declaration(actionsButton, 'min-height')).toBe('30px'); + expect(declaration(actionsButton, 'border-radius')).toBe('999px'); + expect(declaration(actionsButton, 'font-size')).toBe('12px'); + expect(declaration(actionsButton, 'font-weight')).toBe('700'); + + // 版本入口只补「版本名可能很长」这一件事,不再自带边框 / 底色 / 文字色 / hover。 + const trigger = resolved(['.game-run-version-trigger']); + expect(trigger.has('border')).toBe(false); + expect(trigger.has('background')).toBe(false); + expect(trigger.has('color')).toBe(false); + expect(declaration(trigger, 'min-width')).toBe('0'); + // 省略号要真的生效:按钮是 flex 容器,文本必须挂在自带 overflow 的 span 上。 + const label = resolved(['.game-run-version-trigger-label']); + expect(declaration(label, 'overflow')).toBe('hidden'); + expect(declaration(label, 'text-overflow')).toBe('ellipsis'); + expect(declaration(label, 'white-space')).toBe('nowrap'); + expect(hasRule('.game-run-version-trigger:hover')).toBe(false); + expect(hasRule('.game-run-version-trigger:focus-visible')).toBe(false); + + // 版本入口不再绝对定位压在游戏画面上。 + const picker = resolved(['.game-run-version-picker']); + expect(picker.has('position')).toBe(false); + expect(picker.has('top')).toBe(false); + expect(picker.has('right')).toBe(false); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/previewActivation.test.tsx b/apps/ai-game-creator-shell/tests/previewActivation.test.tsx index c349dea8b..68a7af95a 100644 --- a/apps/ai-game-creator-shell/tests/previewActivation.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewActivation.test.tsx @@ -12,6 +12,10 @@ * 只核对内存 registry 并回传 loopback 地址,活着就复用(不重启预览服务),没活着 * (stopped / 不属于这个项目 / 权限位要求确认)才回落到 `start_local_game_preview`。 * + * 成功反馈走工作台壳的 toast(`onRunNotice`),**不再**写进对话区:那条「已载入客户端 + * 运行视图:URL」以前常驻对话底部挡住运行画面。用例因此直接断言这条通道,并反过来钉住 + * 对话容器里没有这类提示。 + * * 这条行为过去只剩 `scripts/check-native-shells.mjs` 的源码守卫与 Rust 实现,前端没有 * 用例;一旦有人再把它当死链路删掉,守卫会先报「missing await invoke」而不知道现场。 */ @@ -41,7 +45,10 @@ function createFixtureManifest(): GameCreationAppManifest { * `activate_local_game_preview` 的替身由用例给定:它决定「这条预览还活着吗」, * 其余命令沿用聊天 harness,运行入口之外的链路保持真实形状。 */ -function installTauri(activateLocalGamePreview: () => unknown) { +function installTauri( + activateLocalGamePreview: () => unknown, + options: { startFails?: string } = {}, +) { const manifest = createFixtureManifest(); const chatHarness = createProjectChatRuntimeHarness({ projectPath: PROJECT_PATH, @@ -60,6 +67,9 @@ function installTauri(activateLocalGamePreview: () => unknown) { return activateLocalGamePreview(); } if (command === 'start_local_game_preview') { + if (options.startFails) { + throw new Error(options.startFails); + } return { url: PREVIEW_URL, port: 43210, root: PROJECT_PATH }; } return chatHarness.invoke(command, args); @@ -74,20 +84,28 @@ function installTauri(activateLocalGamePreview: () => unknown) { /** 「运行」入口由工作台壳的播放请求驱动,这里直接用同一形状的请求触发。 */ function renderRunningProjectChat() { - return render( + const onRunNotice = vi.fn(); + render( , ); + return { onRunNotice }; } -/** 运行结果由聊天自己的本地消息流展示,这里按对话容器文本断言。 */ -async function expectChatAnnouncement(text: string) { +/** 运行反馈走 toast;对话容器里不该再出现这类过程提示。 */ +async function expectRunNoticeWithoutChatTip( + onRunNotice: ReturnType, + message: string, +) { + await waitFor(() => expect(onRunNotice).toHaveBeenCalledWith({ message })); const surface = await screen.findByLabelText('陶泥儿项目对话'); - await waitFor(() => expect(surface.textContent ?? '').toContain(text)); + expect(surface.textContent ?? '').not.toContain('运行通过'); + expect(surface.textContent ?? '').not.toContain('已切换到客户端运行视图'); } beforeEach(() => { @@ -103,14 +121,14 @@ describe('运行入口切到已经在跑的客户端预览', () => { root: PROJECT_PATH, })); - renderRunningProjectChat(); + const { onRunNotice } = renderRunningProjectChat(); await waitFor(() => expect(invoke).toHaveBeenCalledWith('activate_local_game_preview', { projectPath: PROJECT_PATH, }), ); - await expectChatAnnouncement(`已切换到客户端运行视图:${PREVIEW_URL}`); + await expectRunNoticeWithoutChatTip(onRunNotice, '已载入客户端运行视图'); expect(invokeCounts.get('start_local_game_preview') ?? 0).toBe(0); }); @@ -122,15 +140,16 @@ describe('运行入口切到已经在跑的客户端预览', () => { root: null, })); - renderRunningProjectChat(); + const { onRunNotice } = renderRunningProjectChat(); await waitFor(() => expect(invokeCounts.get('start_local_game_preview') ?? 0).toBeGreaterThan( 0, ), ); - await expectChatAnnouncement( - `运行通过,已载入客户端运行视图:${PREVIEW_URL}`, + await expectRunNoticeWithoutChatTip( + onRunNotice, + '运行通过,已载入客户端运行视图', ); }); @@ -139,15 +158,34 @@ describe('运行入口切到已经在跑的客户端预览', () => { throw new Error('需要先确认预览权限'); }); - renderRunningProjectChat(); + const { onRunNotice } = renderRunningProjectChat(); await waitFor(() => expect(invokeCounts.get('start_local_game_preview') ?? 0).toBeGreaterThan( 0, ), ); - await expectChatAnnouncement( - `运行通过,已载入客户端运行视图:${PREVIEW_URL}`, + await expectRunNoticeWithoutChatTip( + onRunNotice, + '运行通过,已载入客户端运行视图', ); }); + + it('启动预览失败时走失败色提示,不再写进对话区', async () => { + installTauri( + () => ({ status: 'stopped', url: null, port: null, root: null }), + { startFails: '预览端口被占用' }, + ); + + const { onRunNotice } = renderRunningProjectChat(); + + await waitFor(() => + expect(onRunNotice).toHaveBeenCalledWith({ + tone: 'error', + message: '运行游戏失败:预览端口被占用', + }), + ); + const surface = await screen.findByLabelText('陶泥儿项目对话'); + expect(surface.textContent ?? '').not.toContain('预览端口被占用'); + }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx index c30d6d682..aabf0d912 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx @@ -356,23 +356,12 @@ describe('「生成任务」侧栏', () => { expect(onToggleOpen).toHaveBeenCalledTimes(1); }); - test('锚点按工作面分档:资源画布 / 运行表现层 / UI 编辑器各挂一档', () => { - const { container, rerender } = renderSidebar([], { placement: 'run' }); + test('锚点按工作面分档:资源画布 / UI 编辑器各挂一档', () => { + const { container, rerender } = renderSidebar([], { placement: 'editor' }); const placementOf = () => container .querySelector('.game-resource-generation-tasks-anchor') ?.getAttribute('data-generation-tasks-placement'); - expect(placementOf()).toBe('run'); - - rerender( - , - ); expect(placementOf()).toBe('editor'); // 缺省 = 资源栏目画布,老调用方不传这个 prop 也落在画布右上角。 diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts index d3aea4611..f4ebcccdd 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts @@ -231,7 +231,7 @@ describe('「生成任务」侧栏样式', () => { expect(sidebar.has('left')).toBe(false); }); - test('锚点贴在工作面右上角,运行表现层让开版本入口,开关沿用次级胶囊样式', () => { + test('锚点贴在工作面右上角,运行表现层不挂这一档,开关沿用次级胶囊样式', () => { const anchor = resolved(['.game-resource-generation-tasks-anchor']); // 锚点是 stage 网格里与工作面同一个单元格的条目:贴右贴顶由网格给,不再用写死 top 的绝对定位 //(写死的 top 会被工具条换行顶穿,验收现场那条「生成任务和菜单栏重叠」就是这么来的)。 @@ -244,14 +244,15 @@ describe('「生成任务」侧栏样式', () => { // 画布顶边内缩一小段,不压在工具条那一行上。 expect(declaration(anchor, 'margin')).toBe('0.85rem'); - // 运行表现层右上角被版本入口(`game-run-version-picker`:top 20px / right 20px)占着, - // 这一档必须把开关压到那枚版本入口下面(内缩上边距 3.5rem)。 - const runAnchor = resolved([ - '.game-resource-generation-tasks-anchor', - ".game-resource-generation-tasks-anchor[data-generation-tasks-placement='run']", - ]); - expect(declaration(runAnchor, 'grid-row')).toBe('2 / -1'); - expect(declaration(runAnchor, 'margin')).toContain('3.5rem'); + // 运行表现层不再挂这个锚点(运行页要留给游戏画面,见 `project-development/index.tsx`): + // 「让开右上角版本入口」那一档坐标随之退役,留一条死规则在这里只会误导下一次改动。 + expect( + widthRules.some((rule) => + rule.selectors.includes( + ".game-resource-generation-tasks-anchor[data-generation-tasks-placement='run']", + ), + ), + ).toBe(false); // UI 编辑器那一档与资源画布同档(第二行),不引入第三套坐标。 const editorAnchor = resolved([ diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx index bee83446f..b6c627d0b 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx @@ -170,6 +170,55 @@ describe('「生成任务」侧栏的开合与入口位置', () => { ).toBe(true); }); + it('运行页不挂「生成任务」入口与面板,切回资源页又回来', async () => { + installInvoke(); + const manifest = createGameCreationAppManifest( + 'workbench-run-hides-tasks-entry', + '运行页隐藏生成任务', + ); + // 有已完成的可运行原型:运行页签可用,但没有活预览,所以首屏仍停在资源管理。 + manifest.tasks = manifest.tasks.map((task) => + task.id === 'code-prototype' ? { ...task, status: 'completed' } : task, + ); + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: '/tmp/workbench-run-hides-tasks-entry', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + // 资源页照旧有入口与锚点。 + expect( + await screen.findByRole('button', { name: '生成任务' }), + ).not.toBeNull(); + expect( + document.querySelector('.game-resource-generation-tasks-anchor'), + ).not.toBeNull(); + + // 运行页:入口、面板、锚点一起消失,画面右上角只留版本入口与预览地址小字。 + fireEvent.click(screen.getByRole('tab', { name: '运行' })); + await waitFor(() => + expect(screen.getByLabelText('运行表现层')).not.toBeNull(), + ); + expect( + document.querySelector('.game-resource-generation-tasks-anchor'), + ).toBeNull(); + expect(screen.queryByRole('button', { name: /^生成任务/ })).toBeNull(); + + // 切回资源页入口回来:任务只是在这页不显示,没有被丢掉。 + fireEvent.click(screen.getByRole('tab', { name: '资源管理' })); + expect( + await screen.findByRole('button', { name: '生成任务' }), + ).not.toBeNull(); + }); + it('「依赖 / 类型」与前一按钮之间的间距跟行内其他按钮一致', () => { const styles = readFileSync( repoPath('apps/ai-game-creator-shell/src/styles.css'), diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx index b9383a561..c98db582b 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { + composerValue, createGameCreationAppManifest, findResourceSelectButton, fireEvent, @@ -368,6 +369,17 @@ function panelPromptText(panel: HTMLElement) { return within(panel).getByLabelText('快速编辑提示词').textContent ?? ''; } +/** + * 「重开面板后提示词应当回填」不能同步读。 + * + * 面板(dialog)先出现,Lexical 的编辑器值随后才落;同步读 `textContent` 在并发跑全量时 + * 会偶发读到空串——CI run 2702 与本地一次全量各命中过一次(同一条用例、`expected '' to + * include '把角色头发改成红色'`)。这里等值真的落位,不再赌时序。 + */ +async function expectPanelPromptContains(panel: HTMLElement, expected: string) { + await waitFor(() => expect(panelPromptText(panel)).toContain(expected)); +} + /** 关掉画布浮层:Esc 与点外部同一条既有链路(`clearResourceCanvasFocus`)。 */ async function dismissPanel() { fireEvent.keyDown(document, { key: 'Escape' }); @@ -408,7 +420,7 @@ describe('快速编辑草稿的保留与恢复', () => { await dismissPanel(); const reopened = await openQuickEditPanel('source-art.png'); - expect(panelPromptText(reopened)).toContain('把夜色改成星空'); + await expectPanelPromptContains(reopened, '把夜色改成星空'); expect( document.querySelector('[data-resource-reference-id="source-rules"]'), ).not.toBeNull(); @@ -438,13 +450,15 @@ describe('快速编辑草稿的保留与恢复', () => { ); await dismissPanel(); - expect( - panelPromptText(await openQuickEditPanel('source-art.png')), - ).toContain('第一张的草稿'); + await expectPanelPromptContains( + await openQuickEditPanel('source-art.png'), + '第一张的草稿', + ); await dismissPanel(); - expect( - panelPromptText(await openQuickEditPanel('source-art-2.png')), - ).toContain('第二张的草稿'); + await expectPanelPromptContains( + await openQuickEditPanel('source-art-2.png'), + '第二张的草稿', + ); }); it('恢复入口按草稿计数,「继续编辑」重开面板并回填,丢弃后入口消失', async () => { @@ -486,7 +500,7 @@ describe('快速编辑草稿的保留与恢复', () => { const resumed = await screen.findByRole('dialog', { name: '快速编辑图片', }); - expect(panelPromptText(resumed)).toContain('把角色头发改成红色'); + await expectPanelPromptContains(resumed, '把角色头发改成红色'); await dismissPanel(); fireEvent.click( @@ -539,7 +553,7 @@ describe('快速编辑草稿的保留与恢复', () => { ).not.toBeNull(); const resumed = await openQuickEditPanel('source-art.png'); - expect(panelPromptText(resumed)).toContain('把夜色改成星空'); + await expectPanelPromptContains(resumed, '把夜色改成星空'); }); it('正规化换掉卡片投影后,草稿跟着资源走、重开在正式素材上继续', async () => { @@ -605,7 +619,7 @@ describe('快速编辑草稿的保留与恢复', () => { // 旧卡片已经不在画布上:草稿按**路径**归属,新投影的正式素材卡照样命中,否则这一笔 // 就再也点不到。 const reopened = await openQuickEditPanel('task-art.png'); - expect(panelPromptText(reopened)).toContain('把夜色改成星空'); + await expectPanelPromptContains(reopened, '把夜色改成星空'); }); /** @@ -664,7 +678,7 @@ describe('快速编辑草稿的保留与恢复', () => { ); const reopened = await openQuickEditPanel('task-art.png'); - expect(panelPromptText(reopened)).toContain('把夜色改成星空'); + await expectPanelPromptContains(reopened, '把夜色改成星空'); }); it('原生恢复队列与本地草稿共用同一枚入口,两边都能继续', async () => { @@ -732,7 +746,11 @@ describe('快速编辑草稿的保留与恢复', () => { ); const reopened = await openQuickEditPanel('source-art.png'); - expect(panelPromptText(reopened)).not.toContain('把夜色改成星空'); + // 用 `composerValue` 读(它内部先让 Lexical 落值):同步读会在值未落时拿到空串, + // 让这条否定断言空过——那正是上面那条回填断言的同一类竞态。 + expect( + await composerValue(within(reopened).getByLabelText('快速编辑提示词')), + ).not.toContain('把夜色改成星空'); }); }); @@ -815,7 +833,7 @@ describe('提交中的快速编辑进「生成任务」侧栏', () => { // 回到第一张:草稿照旧回填,但这一笔已经在跑——再点提交必须被拦下。 const reopened = await openQuickEditPanel('source-art.png'); - expect(panelPromptText(reopened)).toContain('把嘴改小一点'); + await expectPanelPromptContains(reopened, '把嘴改小一点'); fireEvent.click(within(reopened).getByRole('button', { name: '修改' })); expect( await within(reopened).findByText( diff --git a/apps/ai-game-creator-shell/tests/runNoticeShellWiring.test.tsx b/apps/ai-game-creator-shell/tests/runNoticeShellWiring.test.tsx new file mode 100644 index 000000000..04209b358 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/runNoticeShellWiring.test.tsx @@ -0,0 +1,158 @@ +/** @vitest-environment jsdom */ + +/** + * 运行提示的外壳级接线:`ProjectChat` 发一条提示,工作台壳真的把它渲染成浮层。 + * + * 单测(`runNoticeToast`)只证明组件本身,`previewActivation` 只证明 App 会调这条通道; + * 从「通道被调用」到「用户看到 toast」之间还差一层壳的接线(prop 名、handler、portal 挂点、 + * tone 传递)。这里用替身聊天把这一层单独钉住:以后有人把 `` 挪进条件 + * 分支、或在传 prop 时写错名字,这条会红。 + */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { ProjectChatComponentProps } from '../src/features/app-shell/model'; +import { WorkspaceLauncherShell } from '../src/features/app-shell/WorkspaceLauncher'; +import { + createProjectChatRuntimeHarness, + pickProjectFromLauncher, + testAuthUser, +} from './appSurface/harness'; + +const PROJECT_PATH = '/tmp/run-notice-shell-project'; + +function StubRunNoticeChat({ onRunNotice }: ProjectChatComponentProps) { + return ( +
+ + +
+ ); +} + +/** + * 打开工作台所需的最小命令集照抄 `home.suite` 的活动清单用例:项目目录探测 + 清单 + + * 资源图 / 布局读回,其余命令沿用聊天 harness。 + */ +function renderWorkbench() { + const manifest = createGameCreationAppManifest( + 'run-notice-shell-project', + '运行提示接线项目', + ); + const runtimeHarness = createProjectChatRuntimeHarness({ + projectPath: PROJECT_PATH, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'inspect_local_project_directory') { + return { + projectPath: PROJECT_PATH, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'read_local_project_resource_graph') { + return { + resourceIds: [], + referenceEdges: [], + taskFlows: [], + connectionIndex: [], + producerAssignments: [], + dependencyDepths: [], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + } + 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, + }; + } + return runtimeHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke: invoke as never }, + event: { listen: runtimeHarness.listen as never }, + }; + render( + , + ); + pickProjectFromLauncher(PROJECT_PATH); +} + +function toastElement() { + return document.querySelector('[data-project-run-notice-toast="true"]'); +} + +describe('运行提示的外壳接线', () => { + it('成功提示渲染成浮层,且不落在对话容器里', async () => { + renderWorkbench(); + await screen.findByLabelText('项目开发工作台'); + + fireEvent.click( + await screen.findByRole('button', { name: '触发成功提示' }), + ); + + await waitFor(() => expect(toastElement()).not.toBeNull()); + expect(toastElement()?.textContent ?? '').toContain( + '运行通过,已载入客户端运行视图', + ); + // 「对话区里没有这条提示」由 `previewActivation` 用真实聊天容器断言;这里用的替身 + // 聊天没有消息列表,重复断言只会自证。 + }); + + it('失败提示按 alert 渲染', async () => { + renderWorkbench(); + await screen.findByLabelText('项目开发工作台'); + + fireEvent.click( + await screen.findByRole('button', { name: '触发失败提示' }), + ); + + await waitFor(() => + expect(toastElement()?.querySelector('[role="alert"]')).not.toBeNull(), + ); + expect(toastElement()?.textContent ?? '').toContain( + '运行游戏失败:预览端口被占用', + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/runNoticeToast.test.tsx b/apps/ai-game-creator-shell/tests/runNoticeToast.test.tsx new file mode 100644 index 000000000..3739e17bf --- /dev/null +++ b/apps/ai-game-creator-shell/tests/runNoticeToast.test.tsx @@ -0,0 +1,120 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { RunNoticeToast } from '../src/features/app-shell/RunNoticeToast'; + +const RUN_MESSAGE = '运行通过,已载入客户端运行视图'; + +function toastElement() { + return document.querySelector('[data-project-run-notice-toast="true"]'); +} + +describe('运行 / 预览浮层提示', () => { + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it('没有提示时不渲染任何浮层', () => { + render(); + + expect(toastElement()).toBeNull(); + expect(screen.queryByText(RUN_MESSAGE)).toBeNull(); + }); + + it('在浮层里显示提示,并在到点后自动收起', () => { + vi.useFakeTimers(); + const onDismiss = vi.fn(); + + render( + , + ); + + expect(toastElement()).not.toBeNull(); + expect(screen.getByText(RUN_MESSAGE)).not.toBeNull(); + + act(() => { + vi.advanceTimersByTime(2_600); + }); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('同一句文案连续触发时重新计时', () => { + vi.useFakeTimers(); + const onDismiss = vi.fn(); + const { rerender } = render( + , + ); + + rerender( + , + ); + act(() => { + vi.advanceTimersByTime(2_000); + }); + expect(onDismiss).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(600); + }); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('失败提示用错误色调,不需要调用方再拼一套文案', () => { + render( + , + ); + + const toast = toastElement()?.querySelector( + '.platform-runtime-status-toast', + ); + // `PlatformRuntimeStatusToast` 的失败态就是 alert + assertive,成功态是 status + polite。 + expect(toast?.getAttribute('role')).toBe('alert'); + expect( + screen.getByText('在浏览器打开失败:permission denied'), + ).not.toBeNull(); + }); + + it('失败提示比成功提示留得久:2.6 秒不该把错误收走', () => { + vi.useFakeTimers(); + const onDismiss = vi.fn(); + render( + , + ); + + act(() => { + vi.advanceTimersByTime(2_600); + }); + expect(onDismiss).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(3_400); + }); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx b/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx new file mode 100644 index 000000000..029445850 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx @@ -0,0 +1,119 @@ +/** @vitest-environment jsdom */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import ProjectDevelopmentView from '../src/view/project-development'; + +const openUrl = vi.fn(async () => undefined); +vi.mock('@tauri-apps/plugin-opener', () => ({ + openUrl: (url: string) => openUrl(url), +})); + +const PREVIEW_URL = 'http://127.0.0.1:4173/'; + +function installInvoke() { + window.__TAURI__ = { + core: { + invoke: vi.fn(async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return { + resourceIds: [], + referenceEdges: [], + taskFlows: [], + categories: [], + diagnostics: [], + }; + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + throw new Error(`unexpected invoke ${command}`); + }), + }, + } as unknown as typeof window.__TAURI__; +} + +function renderRunView(options: { withPreview?: boolean } = {}) { + installInvoke(); + const manifest = createGameCreationAppManifest( + 'run-preview-browser-open', + '运行页浏览器入口', + ); + const onNotice = vi.fn(); + render( + 项目总控} + onHomeOpen={vi.fn()} + onProjectsOpen={vi.fn()} + onNotice={onNotice} + />, + ); + return { onNotice }; +} + +afterEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe('运行页「在浏览器打开」', () => { + it('点顶栏那枚按钮就把当前预览地址交给系统浏览器', async () => { + renderRunView(); + + const entry = await screen.findByRole('button', { + name: '在浏览器打开', + }); + // 入口住在工作台顶栏的动作区里,与版本入口同一排;预览地址本身不再以文字出现。 + expect(entry.closest('.game-workbench-view-actions')).not.toBeNull(); + expect(document.body.textContent ?? '').not.toContain(PREVIEW_URL); + expect(document.querySelector('.game-run-status-hint')).toBeNull(); + fireEvent.click(entry); + + await waitFor(() => expect(openUrl).toHaveBeenCalledWith(PREVIEW_URL)); + // 成功不弹提示:浏览器真的起来了,用户自己看得见。 + expect(openUrl).toHaveBeenCalledTimes(1); + }); + + it('没有活预览时不渲染这枚入口', async () => { + renderRunView({ withPreview: false }); + + await screen.findByRole('tab', { name: '运行' }); + expect(screen.queryByRole('button', { name: '在浏览器打开' })).toBeNull(); + }); + + it('打开失败时把原因交给统一提示通道,而不是静默吞掉', async () => { + openUrl.mockRejectedValueOnce(new Error('permission denied')); + const { onNotice } = renderRunView(); + + fireEvent.click( + await screen.findByRole('button', { name: '在浏览器打开' }), + ); + + await waitFor(() => + expect(onNotice).toHaveBeenCalledWith({ + tone: 'error', + message: '在浏览器打开失败:permission denied', + }), + ); + }); +}); diff --git a/deploy/container/README.md b/deploy/container/README.md index 8e9dece50..e03b4dddf 100644 --- a/deploy/container/README.md +++ b/deploy/container/README.md @@ -138,7 +138,13 @@ install -m 600 deploy/container/gitea-ci-cache.config.example.json /etc/genarrat docker compose -f deploy/container/gitea-runner-fetch-gate.compose.yml up -d ``` -**首次接入或从不跟踪任务的旧网关升级,需要空闲维护窗口**:确认无活跃 CI 且暂停新 CI 触发,再备份现有 runner 配置和注册文件,在 runner 部署的 `GITEA_INSTANCE_URL` 及 `/data/.runner` 的 `address` 中改用 `http://gitea-runner-fetch-gate:8080`,保留其余注册字段。同时在 `/data/config.yaml` 的 `runner.envs` 中设置 `GENARRATIVE_GITEA_REPOSITORY_URL: "http://gitea:3000/GenarrativeAI/Genarrative.git"`,与维护器的 `repository_url` 一致,沿用现有 job 已可达的内部 Git 通道。此专用变量也覆盖旧 PR 的 checkout;不要用同名 GITHUB_SERVER_URL 环境变量代替,Runner 会再次覆盖它。按原流程重启并验证 runner 注册。 +**首次接入或从不跟踪任务的旧网关升级,需要空闲维护窗口**:确认无活跃 CI 且暂停新 CI 触发,再备份现有 runner 配置和注册文件,在 runner 部署的 `GITEA_INSTANCE_URL` 及 `/data/.runner` 的 `address` 中改用 `http://gitea-runner-fetch-gate:8080`,保留其余注册字段;runner 进程的 `NO_PROXY` / `no_proxy` 必须加入 `gitea-runner-fetch-gate`。同时在 `/data/config.yaml` 的 `runner.envs` 中设置 `GENARRATIVE_GITEA_REPOSITORY_URL: "http://genarrative-station/git/GenarrativeAI/Genarrative.git"`,与维护器的 `repository_url` 一致。当前 job 通过 `--add-host=genarrative-station:172.30.0.3` 访问现有 actions gateway,job 的代理例外保留 `genarrative-station`;runner 自身能访问的 `http://gitea:3000` 不代表内层 job 也能访问。此专用变量也覆盖旧 PR 的 checkout;不要用同名 GITHUB_SERVER_URL 环境变量代替,Runner 会再次覆盖它。按原流程重启并验证 runner 注册。 + +维护器的 `clone_url` 属于宿主网络:当前 Gitea 将容器 3000 映射到宿主 3003,因此使用 `http://127.0.0.1:3003/GenarrativeAI/Genarrative.git`,避免本机完整 clone 绕公网导致 900 秒超时;不要把这个 loopback 地址传给 job。其它部署必须按实际端口核实。`api_url` 仍使用 HTTPS 公开 API 地址。 + +内层 Docker 的默认地址池必须避开外层 `gitea-actions` 的 `172.30.0.0/24` 和 egress 的 `172.31.0.0/24`。当前 `/opt/gitea-stack/config/runner-dockerd-run` 在 dockerd 参数中设置 `--default-address-pool base=10.240.0.0/16,size=24`,每个 job 获得独立 `/24`。变更在 runner 重启后生效,只影响新网络;先逐个检查并定向删除无容器引用的 `GITEA-ACTIONS-TASK-*` 遗留网络,不能全局 prune。取消或强制停止 runner 可能留下空网络,默认 Docker `/16` 地址池累积到 `172.30.0.0/16` 会截走网关流量。 + +恢复领取前,必须在内层 Docker 新建与 job 相同的 bridge,使用现役 CI Image ID、相同 host 映射和代理环境,实际执行 `genarrative-gitea-checkout` 并访问 `http://genarrative-station/git/api/v1/version`;同时确认新网络使用上述 `/24` 地址池。仅宿主 `git ls-remote`、runner 注册成功或 FetchTask 路由通过,均不能替代 job 网络验收。 不能仅改磁盘文件却不让进程加载;维护器还会检查独立 checkout URL,并确认入口实际见到了当前容器本次启动后的 FetchTask 来源 IP。此一次接入不由维护器冒险猜测空闲,也不对运行中 CI 动手。实例 API 根地址仍使用配置中的 HTTPS Gitea 地址,不能指向只支持 runner RPC 的入口。上传脚本从独立 `GENARRATIVE_GITEA_REPOSITORY_URL` 推导真实 Gitea 地址,使用本任务临时凭据调用原生 V4 API;不依赖被网关覆盖的 `GITHUB_SERVER_URL` / `ACTIONS_RUNTIME_URL`。Gitea 1.26.4 的仓库 REST 下载接口只支持 V4,不能换回 V3 上传。宿主下载只跟随同一 HTTPS Gitea origin 的签名重定向,不转发长期 Token;如配置外部对象存储直出,需另行适配下载来源。 diff --git a/deploy/container/gitea-ci-cache.config.example.json b/deploy/container/gitea-ci-cache.config.example.json index c10600468..9b21303ed 100644 --- a/deploy/container/gitea-ci-cache.config.example.json +++ b/deploy/container/gitea-ci-cache.config.example.json @@ -1,8 +1,8 @@ { "api_url": "https://git.genarrative.world/git/api/v1", "repository": "GenarrativeAI/Genarrative", - "repository_url": "http://gitea:3000/GenarrativeAI/Genarrative.git", - "clone_url": "https://git.genarrative.world/git/GenarrativeAI/Genarrative.git", + "repository_url": "http://genarrative-station/git/GenarrativeAI/Genarrative.git", + "clone_url": "http://127.0.0.1:3003/GenarrativeAI/Genarrative.git", "runner_container": "gitea-runner", "token_file": "/etc/genarrative-ci-cache/api-token", "state_dir": "/var/lib/genarrative-ci-cache", diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9d32fe1c7..7f0647d1a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,7 +1,17 @@ # 决策记录 +## 2026-09-22 release 每日调度纳入正式 Full Build 并收集结果 + +- 背景:原 `Genarrative-Scheduled-Release-Trigger` 只处理 AGC Windows/macOS,且对下游使用 `wait: false` 后立即推进 revision;正式服务端 release 仍只能人工触发,客户端构建失败也不会回传调度器。 +- 决策:release 调度同时计算 `AGC` 与 `FullBuild` 两条 scope。服务端相关路径变化时以 `DEPLOY_TARGET=release`、`CONFIRM_RELEASE_DEPLOY_AGENT=true` 触发 `Genarrative-Full-Build-And-Deploy`;客户端相关路径变化时保持统一发号并触发 Windows/macOS release。Full Build、Windows、macOS 三路均等待结果并汇总,只有成功的 lane 才推进对应 `.jenkins-last-release-full-revision` / `.jenkins-last-release-agc-revision`,失败 lane 在下一轮单独补发,避免重复部署已成功的服务端版本。`DATABASE_BACKUP_MODE` 默认 `async`,并显式提供 `STDB_API_ROLLOUT_MODE` / approvers 以保留正式维护门禁。 +- 原因:正式 release 需要与 dev 小时调度隔离,同时不能继续依赖人工触发服务端;lane-level 成功状态可区分“已成功但另一 lane 失败”的部分发布,避免下一次调度重复发布 Full Build。 +- 影响范围:`jenkins/Jenkinsfile.scheduled-release-trigger`、`jenkins/scheduled-release-trigger-job-config.xml`、`scripts/check-production-ops-guardrails.mjs`、开发运维文档与共享开发工作流。 +- 验证方式:生产运维门禁检查 Full Build release 参数、双 scope、`wait: true` 结果聚合、三路分支和成功后才写 lane revision;并用 Jenkins 具体构建验证 Full Build/AGC 的 build number 与结果能回传到调度 Job。 + ## 2026-09-22 AGC release 增加每日调度,dev 调度保持双平台 +> 已被上一条决策取代:release 调度现已纳入正式服务端 Full Build。 + - 背景:原有 `Genarrative-Scheduled-Revision-Trigger` 每小时跟随 revision 发布 dev 客户端,但没有对应的 release 渠道自动入口;Mac 节点此前已纳入小时 dev 调度,需要避免新增 release 调度时再退回 Windows-only。 - 决策:新增 `Genarrative-Scheduled-Release-Trigger`,每天 04:00 检查 `SOURCE_BRANCH`,使用独立的 `.jenkins-last-release-revision` 与客户端相关路径白名单;上一轮 release 调度后有客户端变更时,先经 `Genarrative-Agc-Global-Version-Issue` 发统一总号,再以同一固定 `COMMIT_HASH` 触发 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build` 的 `AGC_UPDATE_CHANNEL=release` 分区,Mac 继续带 `SKIP_IF_SUPERSEDED=true`。小时 dev 调度保持同时触发 Windows 与 macOS dev。 - 原因:release 与 dev 是不同渠道和发布节奏,不能靠同一个小时 Job 隐式切换;独立 Job 能分别去重、记录状态和审计发号。release 调度不触发 Full Build,避免把桌面客户端发布与线上全栈部署绑定。 @@ -9367,6 +9377,17 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`apps/ai-game-creator-shell/src/features/{project-workspace/resourceReferences.ts,project-workspace/ResourceReferenceInput.tsx,resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx,resource-canvas/resourceCanvasAssetGenerationTaskModel.ts,resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts}`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx` 与对应 6 个定向测试文件。 - 验证:定向 `resourceCanvasAssetGenerationReferences` / `resourceCanvasAssetGenerationBackgroundClose` / `resourceCanvasBottomToolbar` / `resourceCanvasGenerationFloatingPanel(Chrome)` / `resourceReferenceInput` / `resourceReferences` / `resourceCanvasAssetGenerationTasksPanel` 全绿;全量 `npm run test -- apps/ai-game-creator-shell/tests` 只剩 `clientHttp` / `clientApi` / `clientAuthStorage` / `projectCreationDirectory` / `recentProjectsHook` 五个 jsdom `localStorage` 环境用例红(与本次改动无调用关系);TS typecheck、`check:encoding`、`git diff --check` 通过。未复核真实客户端观感。 +## 2026-09-22 运行页收口:过程提示退出对话区,顶栏统一承载运行入口 + +- 背景:点播放(以及历史上 `/preview`、`/open-preview`、生成后自动启动预览)都会往对话区写一条 assistant 提示(`运行通过,已载入客户端运行视图:http://127.0.0.1:63155/` 这类)。它常驻对话底部遮挡运行画面,也让对话区混进非对话内容;运行页本身还有三处遮挡与两套皮:预览地址是一行常驻小字(不可点)、版本入口是绝对定位压在画面右上角的浮层、右上角还叠着「生成任务」开关。 +- 决策(对话区只留对话内容):运行 / 预览的反馈不再进对话区,**成功与失败都**走工作台壳的 toast——`ProjectChatComponentProps.onRunNotice` → `RunNoticeToast`(同一句连续触发重新计时;成功 2.6 秒收起,失败 6 秒收起,`tone` 决定观感)。失败三处:启动预览报错(`运行游戏失败:…`)、缺 Tauri / 缺项目这两条前置条件、以及「在浏览器打开失败」。原先承载这些文案的 `announceProjectChatMessage` 已无调用方,连同删除。 +- 与上一条的关系(2026-09-21「预览激活回接运行入口」):那条接线决策(先问 Rust 要活体预览、命中就只切视图不重启)原样保留;被本次改掉的只是它当时为这条链路选的**播报渠道**——`经 DirectProjectChatHandle.announce 说一句「已切换到客户端运行视图:」` 按验收反馈(提示常驻对话底部遮挡运行画面)改成 toast,「复用活体预览不重启」的判据与 `tests/previewActivation.test.tsx` 的三条路径不变。 +- 决策(运行页顶栏是唯一入口):运行区域上方的状态行与预览地址小字整体退役(运行画面回到两行栅格);预览地址改成顶栏动作区里的一枚「在浏览器打开」按钮(opener 插件的 `openUrl`,只在有活预览时渲染;`scripts/check-native-shells.mjs` 另加「视图里 `openUrl(` 只有一个调用点」的判据);版本入口搬进同一个顶栏动作区,外观复用 `.game-workbench-view-actions button` 的基础规则,不再自带边框 / 底色 / hover,也不再是绝对定位浮层。版本入口在**资源画布与运行页**显示,UI 编辑器壳里不渲染(那一页是聚焦编辑某个资源的界面)。版本名改用一层 span 承载省略号——按钮是 flex 容器,文本直接挂在按钮上时 `text-overflow` 不生效。 +- 决策(运行页不挂生成任务):`ResourceCanvasAssetGenerationTasksPanelView` 只在资源画布 / UI 编辑器出现,运行页的入口、面板与锚点都不渲染;`[data-generation-tasks-placement='run']` 那一档坐标与组件 `placement` 联合类型里的 `run` 一并删除。任务不丢,切回资源页即可见。 +- 边界:预览的**启动与切换**仍然只走内置运行画面、不自动开系统浏览器——`scripts/check-native-shells.mjs` 那条负向守卫保持原样,本轮只补「浏览器入口只有顶栏这一枚按钮」的正向断言。 +- 影响面:`apps/ai-game-creator-shell/src/{App.tsx,styles.css,view/project-development/index.tsx,features/app-shell/*,features/resource-canvas/*}`;用例新增 `tests/runPreviewBrowserOpen.test.tsx`、`tests/runNoticeToast.test.tsx`、`tests/runNoticeShellWiring.test.tsx`(外壳级:替身聊天发提示 → 壳真的渲染浮层)、`tests/gameRunToolbarActionsStyle.test.ts`,改写 `tests/previewActivation.test.tsx`(原断言「聊天里出现已载入运行视图」的地方改为断言 toast 通道 + 对话容器里没有这类提示,并补一条「启动失败走失败色提示且不写对话区」)与 `tests/appSurface/project-development.suite.ts` 的生成任务入口用例。 +- 验证:`appSurface` 全量、AGC 壳目录全量、`npm run typecheck`、`check:native-shells:contract`、`check:encoding`、`git diff --check` 全绿;真机观感未复验。 + ## 2026-09-22 DirectProject 聊天状态显式化:回合三态 + 「在跑吗」唯一派生入口 + 数据流地图 - 背景:DirectProject 聊天框只有三份真相源(`project.jsonl` 历史切片、Thread Manager 运行态事件、本地乐观消息),但「这一轮在跑吗」在四层里各叫一个名字——reducer 的 `turnRunning`、controller 的 `turnBusy`、视图里手拼的 `busy`、投影里的 `active`。定位发送后空窗缺陷时,读代码无法判断某个窗口期的界面表现是否有依据,也说不清谁该信谁。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 8977e7861..6aaee50b9 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -92,7 +92,7 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m ## Jenkins 定时版本调度 -定时与版本比较收口到两条调度器:`Genarrative-Scheduled-Revision-Trigger` 每小时处理 dev 渠道,变化时触发 Full Build、AGC Windows/macOS dev,并让两个客户端平台共用同一个总版本号;`Genarrative-Scheduled-Release-Trigger` 每天 04:00 处理 AGC release 渠道,只在上一轮 release 调度后出现客户端相关路径变化时,经 `Genarrative-Agc-Global-Version-Issue` 发同一个总号并触发 AGC Windows/macOS release。各下游 Job 都不得自带 `triggers` / `cron`,也不得在管线内再做一套版本去重;`npm run check:production-ops` 会拦住回退。macOS 节点是日常办公机,两条调度触发它时都置 `SKIP_IF_SUPERSEDED=true`:节点离线期间排队的旧构建在恢复后会自行让位,不发布过期版本。调度状态与生效步骤见 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +定时与版本比较收口到两条调度器:`Genarrative-Scheduled-Revision-Trigger` 每小时处理 dev 渠道,变化时触发 Full Build、AGC Windows/macOS dev,并让两个客户端平台共用同一个总版本号;`Genarrative-Scheduled-Release-Trigger` 每天 04:00 按服务端与客户端两条独立 scope 处理 release,服务端变化时用 `DEPLOY_TARGET=release` 触发正式 Full Build,客户端变化时经 `Genarrative-Agc-Global-Version-Issue` 发同一个总号并触发 AGC Windows/macOS release。两条调度都等待并汇总下游,按成功 lane 推进 revision;失败 lane 下一轮单独补发。各下游 Job 都不得自带 `triggers` / `cron`,也不得在管线内再做一套版本去重;`npm run check:production-ops` 会拦住回退。macOS 节点是日常办公机,两条调度触发它时都置 `SKIP_IF_SUPERSEDED=true`:节点离线期间排队的旧构建在恢复后会自行让位,不发布过期版本。调度状态与生效步骤见 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## Gitea CI 依赖闭合 @@ -100,6 +100,8 @@ Gitea Rust 缓存自动维护由宿主 `genarrative-ci-cache.timer` 收集同一 修改 Gitea workflow 的 job 显示名称、ID 或缓存导出组时,必须同步维护器的 `JOBS` / `RUST_JOB_IDS`;`test_gitea_cache_maintenance.py` 直接对照实际 workflow 检查全集和导出映射,避免自动刷新或镜像验收因名单漂移长期等待。维护器 `Api.request` 的 `method` 是必填关键字参数,GET 也必须显式指定,不根据 body 推断请求方法。 +Gitea 缓存部署必须区分网络:runner 的 RPC 走 `gitea-runner-fetch-gate:8080`;内层 job 的 checkout/上传走映射到 `172.30.0.3` 的 `http://genarrative-station/git`;宿主专用 clone 走 `http://127.0.0.1:3003`。不要把 runner 可达的 `gitea:3000` 配给 job。内层 Docker 使用 `10.240.0.0/16`、每 job `/24` 的默认地址池,避开外层 `172.30/172.31` 网段;恢复领取前必须在真实 job 网络里验证 checkout 与 Gitea API,不能只验证 FetchTask。具体配置与遗留空网络处理见 `deploy/container/README.md`。 + AGC Rust 两条 lane、crates、smoke、Backend 和桌面壳测试使用镜像内可信 sccache 对象快照;Native shell release step 显式清空双 wrapper,前端/repository checks 不启用。仅首次人工 bootstrap 时,维护者通过 `scripts/build-gitea-rust-cache.sh` 从远端 master 在限额、无宿主挂载的临时容器中按实际 cwd/profile/目标预热全部测试组,仅编译、不执行测试/应用;后端 workspace 与 spacetime-module 保持独立,AGC 的三个 cwd 入口之间清理预热 target,防止 fresh 判断漏产缓存键。最终镜像只追加 sccache、对象和来源元数据,不包含源码或 target。容量上限 4 GiB,不替代宿主旧镜像/归档清理。PR 只写当前容器层、不回传,不开放 Docker API/发布权限;继续禁用 incremental。`ci-rust-cache.sh` 在快照缺失、工具链不符或 wrapper 探测失败时直接编译,并隔离远程缓存配置和 daemon。分片日志记录编译耗时,收尾输出命中统计;两个 lane 的测试和前置检查不同,耗时差不是严格 A/B。线上存在活跃 CI 时不得重启 runner 或切换标签;全组启用前须刷新完整快照并逐组验证,详见开发运维文档。 `.gitea/workflows/project-ci.yml` 的客户端门禁拆成 lane 与功能 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust lane 1/2`、`lane 2/2` 各自预取一次 AGC 壳 manifest,并顺序运行两片 Rust bin 单测;`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。两条 Rust lane、smoke job 与 crates job 只用 cargo 与 node 内建模块,因此不执行 `npm ci`。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译后按 `--list` 名单分 4 片:每次分片调用用 `--shard-index=` 只跑自己那片,片内保持 `--test-threads=1` 并使用独立 `TMPDIR`;两条 lane 之间并发,lane 内顺序运行两片,避免重复依赖预热和同一容器内多进程争抢。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片调用都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 9885a558b..7e2d98345 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,20 @@ # 踩坑与排障记录 +## AGC 素材直传的 OSS 权限必须同步到 Native shell 契约检查 + +- **现象**:Native shell CI 在 `check:native-shells:contract` 的 HTTP scope 检查失败,尚未准备 Rust 缓存;后续导出步骤正常退出但实际跳过,导致 master 缓存产物缺组、自动镜像刷新等待。 +- **原因**:`capabilities/main.json` 已为封面与截图直传加入 `https://*.aliyuncs.com/*`,`scripts/check-native-shells.mjs` 的精确白名单仍只有五项,且误以为更新下载迁至原生 updater 后就不再需要 OSS 权限。`assetDirectUpload.ts` 仍通过 Tauri HTTP 插件执行素材直传。 +- **处理**:契约期望同步包含现有 OSS HTTPS 项,保留完整白名单精确比较;不得删除业务所需权限或改成任意 URL 放行。更新下载与素材直传是不同调用链,变更能力配置时同步检查调用方和契约。 +- **验证**:运行 `npm run check:native-shells:contract` 与 `apps/ai-game-creator-shell/tests/assetDirectUpload.test.ts`;缓存完整性以实际 artifact 和导出日志为准,不能仅看上传 step 是否成功。 + +## release 安装包文件名中的编码空格不能按控制字符拒绝 + +- **现象**:release 环境点击“下载客户端”只显示无法获取最新版本,`GET /api/client-downloads` 返回 `502 UPSTREAM_ERROR`;dev 环境正常。 +- **原因**:release 渠道产品名为“陶泥儿 Release”,NSIS 首装包名因此包含空格,OSS `latest.json` 中的 URL 使用 `%20`。`validate_download_url` 的百分号解码校验把 `0x20` 当成控制字符拒绝,Windows 清单被判非法;release macOS 清单当时又未发布,聚合后两端都无下载项并返回 502。 +- **处理(现行口径)**:清单 URL 校验允许百分号编码的空格,继续拒绝其它控制字符、`/`、`\`、DEL、非法编码和跨目录文件名;不得通过改写真实产物名绕过校验。 +- **验证**:`platform-oss` 用 `陶泥儿%20Release_0.1.110_x64-setup.exe` 做清单解析回归;线上修复后接口应返回 release Windows 下载项,macOS 未发布时进入 `unavailablePlatforms`。 +- **关联**:`server-rs/crates/platform-oss/src/client_downloads.rs`、`apps/ai-game-creator-shell/scripts/build-release.mjs`、`docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md`。 + ## 客户端图标素材描述不能先包装再截断 - 现象:资源画布填写了具体图标需求,平台实际收到的 `iconDescriptions` 却只包含泛化的小游戏美术指令,生成结果不遵循输入。 @@ -42,6 +57,14 @@ Copy Artifact 插件在**非 SYSTEM 认证**下按「认证用户」判权:只 `Genarrative-Manual-Build-And-Deploy` 的 `DEPLOY_TARGET=release` 只控制 Stdb / API / Web 全量发布,不会自动成为 AGC 的 `AGC_UPDATE_CHANNEL`。2026-09-21 的手工发布 #10 就因此让 Windows #107 与 macOS #16 使用默认 `dev`,把 `0.1.95` 上传到 `agc/dev-win`、`agc/dev-mac`,而 `agc/release-win/latest.json`、`agc/release-mac/latest.json` 保持 404。现行口径:手工入口按 `release -> release`、`development -> dev` 同时给 Windows 与 macOS AGC Build 传 `AGC_UPDATE_CHANNEL`;补发已烧号的同一版本时用相同 `AGC_RELEASE_VERSION` 直接重跑两条 AGC Job,不重新发号。OSS 发布对象是 `agc/-win|mac/`,不存在 `agc/release/` 这一层。 +## Jenkins 渠道参数会进入 Node 测试进程,默认值测试必须隔离环境 + +- **现象**:release 渠道的 macOS Job 在 Tauri 构建前的 `build-release.test.mjs` 失败,唯一差异是 `no-bundle smoke skips version writes and manifest generation` 期望 `dev`、实际得到 `release`;构建尚未进入 Rust/Tauri 编译。 +- **原因**:Jenkins Job 参数 `AGC_UPDATE_CHANNEL=release` 会成为子进程环境变量,而该测试直接依赖未设置的默认渠道,却只显式传了 `--target` 和 `--no-bundle`。 +- **处理(现行口径)**:断言默认渠道、默认目标等环境派生值的测试,必须用现有 `withEnv` 显式清除相关变量;构建入口继续按 `AGC_UPDATE_CHANNEL` 选择渠道,不为迁就测试改生产逻辑。 +- **验证**:用 `AGC_UPDATE_CHANNEL=release node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs` 复现并验证修复,未设置变量时也必须通过。 +- **关联**:`apps/ai-game-creator-shell/scripts/build-release.test.mjs`、`jenkins/Jenkinsfile.ai-game-creator-shell-macos-build`。 + ## 同一条链路两处上限不一致:平台合法产出被客户端整条丢弃 - 现象:客户端报「生成素材失败:platform-generation-result-unknown: 异步生成完成结果无法绑定到 operationId:External Editor 旧同步结果的图集切片超过 64 个」,而平台侧这次生成**其实已经成功并切完图**(任务账本耗时正常、`assetId` 为空、没有任何素材落盘,付费产物被丢)。 @@ -5993,6 +6016,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` `append_application_log_line` 在落盘前对整行做 `sanitize_diagnostic_message`:行内只要出现 `token=`、`bearer `、`authorization`、`credential`、`api key` / `apikey` / `api_key` 这类标记,**整行**就被换成 ``,只留下时间戳与 `RUST module:` 前缀;同时每行还会被截到 2048 字符。于是把“身份字段 + 诊断正文”拼成一行 `app_log!` 时,正文里一个凭据词就可能让整条记录连 `eventId`、`code` 一起消失(2026-09-21 加统一错误事件的日志投影时按两行落:身份行只放程序生成与调用方常量字段,summary / hint / detail 等自由文本一律只放详情行,且自由文本先自行压平换行——裸词标记脱敏消不掉,自由文本放错行会把 eventId、code 一起带走)。 +## 2026-09-22 本地 node_modules 里的内置 Codex 原生包过旧会让 AGC build script panic + +- **现象**:`npm run agc` 编到壳 crate 的 build script 时中止,stderr 是 `panicked at build.rs:103: Codex 原生包版本、布局或架构不匹配目标 x86_64-pc-windows-msvc`,stdout 只有 `cargo:rustc-env=AGC_BUILD_TARGET=...`。 +- **原因**:`build_support/codex_bundle.rs` 把随包 Codex CLI 钉在一个固定版本,而本地 `node_modules/@openai/codex-win32-x64/vendor//codex-package.json` 还停在上一次安装的旧版本。`package-lock.json` 早就升到新版本,缺的只是本地安装;这条判据只看版本元数据,文件齐全、架构正确也照样拦。 +- **处理(现行口径)**:在仓库根目录执行 `npm ci`(不要改成子目录或单包安装),随后 `node_modules/@openai/codex/package.json` 与 vendor 的 `codex-package.json` 版本应当一致。Windows 上 `npm ci` 会先 unlink 整个 `node_modules`:RustRover 的 Tailwind language server / `oxide-helper` 进程会占住 `@tailwindcss/oxide-*.node`,报 `EPERM: operation not permitted, unlink ...` 时先结束这些 helper 再重试,否则会停在半装状态。 +- **验证**:`npm ci` 后 `cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 通过,不再触发该 panic。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/build.rs`、`apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs`、`package-lock.json`。 + ## 策划聊天不能按可选子节点序号分配消息高度 策划与开发 Agent 共用聊天类名,但布局合同不同。策划新增状态条后,按第二个子节点分配 `1fr` 会把空白给状态条、让消息框随回复增长;只给 `.is-direct-codex` 的状态样式也不会覆盖策划。策划使用纵向 Flex,仅消息列表伸缩,阶段/待处理区域限高滚动;改共用样式时同时核对两种入口。jsdom 交互通过不代表布局正确,须匹配完整 CSS 层叠并用真实浏览器核对空/短/长消息与长待办。窄屏上下堆叠必须在固定外壳内提供工作台滚动容器,两块面板明确限高;低优先级的 `height: auto` 不能覆盖外壳后代规则的 `height: 100%`。布局夹具必须包含窗口外壳与启动器层级,并实际滚动验证输入框可见可操作,不能只检查输入框在聊天面板内部。实时正文/思考不更新持久消息数组,滚动跟随必须覆盖这些独立状态并保留用户上滚门禁;历史消息缺失的时间不得用读取时刻填补。详见 AGC 实施计划的“策划 Agent 对话显示与滚动合同”。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 68388ccc4..d048ec805 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -1,6 +1,6 @@ # AGC 客户端更新检查与下载 -更新时间:`2026-09-21` +更新时间:`2026-09-22` 本文件是 AGC 客户端自动更新的主规范:更新能力由 Tauri 官方插件 `tauri-plugin-updater` 承担,并按下文渠道分发。 @@ -27,6 +27,7 @@ - 数据不跨渠道共享:本地项目、工程快照、模板、登录态、诊断日志与 Runner/项目锁按渠道身份分目录。切渠道等于换一个客户端,不迁移、不合并本地数据;渠道内的 origin 隔离规则不变。 - 主窗口与工作区/启动器窗口标题取构建期产品名,让同机并存的渠道客户端在任务栏与 Alt-Tab 中可区分;默认渠道标题仍是「陶泥儿」。 - 首装包与更新包的对象名包含产品名(如 `陶泥儿 Release_0.1.96_x64-setup.exe`、`陶泥儿 Release_0.1.96_aarch64.dmg`)。清单 `downloads` 地址由发布脚本按本次真实产物派生,禁止写死产品名;首装包选择按 `<版本>_<架构>.dmg` 唯一匹配,不依赖产品名字面量。 +- 产品名中的空格在清单 URL 中必须使用标准百分号编码(如 `%20`);网站服务端允许这种合法文件名编码,但仍拒绝控制字符、路径分隔符、DEL 和非法百分号编码,不得通过改写真实产物名绕过校验。 - Windows 提权 ACL 修复助手按目录名识别安装身份:`<基线>` 与 `<基线>.<渠道>` 都在 AGC 自有的 managed 范围内;相似前缀(例如 `world.genarrative.ai-game-creator-backup`)不在范围内,落回 user-selected 范围或直接拒绝。 ## 非目标 @@ -136,7 +137,7 @@ - 发布入口只解析一次目标,优先级为 CLI `--target value` / `--target=value` / `-t value`、`AGC_BUILD_TARGET`、Windows 默认值;重复/空目标与不支持目标失败关闭。版本高水位、构建 feature/渠道端点、bundle 路径、产物后缀、清单平台键及摘要必须消费同一个发布上下文,不能分别回读默认目标。 - 渠道由 `AGC_UPDATE_CHANNEL` 显式指定,默认 dev;Windows 与 macOS 目标均支持 dev、release 和自定义渠道,目标校验独立进行。 - 渠道 `--config` 在 Tauri 构建前最后合并,同时注入 `productName`、`identifier` 与 updater 端点:安装身份与更新端点必须来自同一个渠道,不能各自回读默认值。macOS 发布入口构建 `*.app`、updater 归档与 DMG 前先按发布渠道解析产品名,产物名一律派生而不写死。 -- 定时调度只在本轮到达的提交包含 AGC 相关路径(客户端、共享包、`server-rs/crates`、AGC 插件、桌面壳图标、根依赖清单)时才触发渠道发布;纯文档或流水线自身的提交只跑 Full Build,不推高客户端版本号。判定失败或勾选强制触发时按"需要发布"处理。 +- 定时调度分别判断服务端与客户端 scope:dev 小时调度在提交含 AGC 相关路径时发布对应渠道,纯文档或流水线自身的提交仍只跑 Full Build;release 每日调度在服务端相关路径变化时发布正式 Full Build,在 AGC 相关路径变化时发布 release 客户端,并在同一调度内等待、汇总各 lane 结果,失败 lane 下一轮补发。判定失败或勾选强制触发时按"需要发布"处理。 - 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。 - 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。 - 上传:安装包与 `.sig` 上传到 `agc/-win|mac//`,清单以 `--force` 覆盖上传到对应分区的 `latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。 diff --git a/docs/technical/【技术方案】AGC总版本号与发号-2026-09-20.md b/docs/technical/【技术方案】AGC总版本号与发号-2026-09-20.md index fdef27adb..0d39ddb27 100644 --- a/docs/technical/【技术方案】AGC总版本号与发号-2026-09-20.md +++ b/docs/technical/【技术方案】AGC总版本号与发号-2026-09-20.md @@ -39,7 +39,7 @@ AGC 客户端此前按「渠道各自比高水位自增」发号:`dev-win` 与 - 发号模块:`apps/ai-game-creator-shell/scripts/agc-global-version.mjs`(读总号 / 播种 / 发号 / 写后回读 / 渠道高水位断言 / dry-run 预览)。 - 发号入口:`apps/ai-game-creator-shell/scripts/issue-global-version.mjs`(CI 与本地共用,输出固定为 `AGC_GLOBAL_VERSION=`)。 - 构建侧:`build-release.mjs` 的 `prepareReleaseVersion()` 优先采用传入总号,未传入时现场发号;`resolveRemoteHighWaterVersion()` 降级为断言来源,只用于「请求号低于本渠道清单版本即失败关闭」。 -- CI:`jenkins/Jenkinsfile.agc-global-version-issue` + `jenkins/agc-global-version-issue-job-config.xml`;dev 调度管线 `jenkins/Jenkinsfile.scheduled-revision-trigger`、每日 release 调度管线 `jenkins/Jenkinsfile.scheduled-release-trigger` 与手动管线先调发号 Job,再把号透传给 AGC Build。 +- CI:`jenkins/Jenkinsfile.agc-global-version-issue` + `jenkins/agc-global-version-issue-job-config.xml`;dev 调度管线 `jenkins/Jenkinsfile.scheduled-revision-trigger`、每日 release 调度管线 `jenkins/Jenkinsfile.scheduled-release-trigger` 与手动管线先调发号 Job,再把号透传给 AGC Build;release 调度的服务端 Full Build 不使用 AGC 总号,只使用固定 `COMMIT_HASH` 与 Full Build 自身版本。 - 发号 Job 的 Copy Artifact 采用生产权限模式,`options` 里的 `copyArtifactPermission(...)` 必须显式列出**全部**消费者:小时 dev 调度 `Genarrative-Scheduled-Revision-Trigger`、每日 release 调度 `Genarrative-Scheduled-Release-Trigger` 与手动发布 `Genarrative-Manual-Build-And-Deploy`。用户触发的构建按「认证用户」判权(SYSTEM 定时构建短路放行),漏列时手动发布会报 `Unable to find project for artifact copy: Genarrative-Agc-Global-Version-Issue`,且失败点在发号之后。改完该 `options` 后必须先单独跑一次发号 Job,让 Declarative Pipeline 把 Job property 写回 Jenkins。 ## 不改的东西 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 2c58e1424..e57caadba 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -141,9 +141,9 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去 普通图片错误素材类型清理使用 `npm run spacetime:editor-image-asset-kind:clean -- --database --server-url `,只能由已授权 migration operator 执行。先进入维护模式并发布包含 `clean_editor_image_asset_kind_and_return` 的 SpacetimeDB module,并保持旧版本 API / controller / worker 停止;随后运行默认全量 dry-run,核对 `asset → project-resource → showcase → canvas` 各 scope 的扫描数、命中行数、字段数和 blocker 均符合预期,再追加 `--apply`。脚本对每批重新 dry-run、绑定包含画布迁移摘要、结构化 layer 与 generation-dialog 权威 JSON 的 SHA-256,最后自动从头复核零命中;任一画布数据异常都会只输出哈希化 ID、scope 与原因并停止,不能跳过。清理只处理精确业务旧值,不修改 `asset_object.asset_kind`、MIME 或媒体类型;project-resource scope 在清行前验证同工程 migration 并将其状态纳入批次 hash,layout version 0 的 legacy 画布可以没有 migration,但 structured 画布缺 migration 必须立即形成 blocker,资源行不得先被清空;清行后能保持原 status 不变量时立即刷新摘要,否则只允许留给后续精确 canvas 字段清理收口。canvas scope 在任何布局写入前再次按 active / backfilled / rolled_back 状态验证原 migration 凭证和双份 legacy / structured 不变量,将 `editor_canvas_generation_dialog.dialog_json` 与 layer rows 一并扫描并在同一事务 patch;只允许本批资源清零及精确字段删除造成的差异,写入后从全部结构化权威行重建 layout、再次复核新状态才受控重签摘要,同时保持业务 revision、migration status 与全部时间戳不变。新版本 API、SpacetimeDB storage 创建入口、legacy 画布元数据提取和项目资源落表边界都会将 trim 后精确等于 `image` 的 `assetKind` 归一为 `NULL`,防止旧页面、滞留请求或 legacy 保存重新制造废弃值。完成零残留复核,并分别确认 cleaned backfilled 可激活、active 可继续保存、rolled_back 可重复复检后恢复应用版本,最后退出维护。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-clean-editor-image-asset-kind.mjs` 与 `scripts/spacetime-migration-common.mjs`。 -`Genarrative-Scheduled-Revision-Trigger` 是 dev 渠道的小时调度入口,每小时检查一次(`H * * * *`,分钟由 Jenkins 按 Job 名散列,不等同于整点)。`Genarrative-Scheduled-Release-Trigger` 是 AGC release 渠道的每日调度入口,固定在每天 04:00(`0 4 * * *`)检查同一个源码分支。两条调度器都只用 `git ls-remote` 解析 `SOURCE_BRANCH`(默认 `master`)的远端 HEAD,不 checkout 工作区;解析出的完整 commit 与各自上一次触发过的 revision 相同则标记 `NOT_BUILT` 并结束,不触发任何下游。 +`Genarrative-Scheduled-Revision-Trigger` 是 dev 渠道的小时调度入口,每小时检查一次(`H * * * *`,分钟由 Jenkins 按 Job 名散列,不等同于整点)。`Genarrative-Scheduled-Release-Trigger` 是 release 渠道服务端与客户端的每日调度入口,固定在每天 04:00(`0 4 * * *`)检查同一个源码分支。两条调度器都只用 `git ls-remote` 解析 `SOURCE_BRANCH`(默认 `master`)的远端 HEAD,不 checkout 工作区;解析出的完整 commit 与对应 lane 上一次成功的 revision 相同则标记 `NOT_BUILT` 并结束,不触发任何下游。 -dev 调度器发现 revision 变化时,把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy`、`Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`,各条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。这些下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`(两个平台分区发布同一个发号 Job 下发的总版本号),纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。每日 release 调度器不触发 Full Build;它使用独立的 `.jenkins-last-release-revision` 与客户端路径白名单,只在上一轮 release 调度后出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`plugins/agc-unity-editor/`、`plugins/agc-godot-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 变更时,先经发号 Job 获取统一总号,再以同一个固定 commit 触发 AGC Windows 与 macOS 的 `release` 分区构建;macOS 仍带 `SKIP_IF_SUPERSEDED=true`,无客户端变化时只推进 release revision 状态、不烧号。dev 调度器的下游各自判定:AGC Windows Build 采用「客户端相关路径白名单」,Full Build 采用「与线上站点 / 后端无关的路径黑名单」(`docs/`、`.codex/`、`jenkins/`、`apps/ai-game-creator-shell/`、`apps/mobile-shell/`、`apps/desktop-shell/`、`apps/preview-deployer-web/`、`tools/`、根级 `*.md`),改动只要落在黑名单之外就会照常部署,避免漏发线上站点或后端;两条同时被判为跳过时调度管线只推进 revision 状态、不触发任何发布。AGC 客户端版本号不再由渠道各自递增:唯一发号源是 OSS 对象 `agc/global-version.json`,发号收口到 `Genarrative-Agc-Global-Version-Issue`(`disableConcurrentBuilds()` + 写后回读校验;集群未装 `lockable-resources` 插件)。dev / release 两条调度管线与手动发布管线都先调用该 Job 发号,再用归档产物 `agc-global-version.txt` 读取总号并作为 `AGC_RELEASE_VERSION` 透传给客户端构建;统一构建只发一次号供各渠道共用,单渠道热修只把号传给该渠道。显式传入的号低于本渠道当前清单版本时构建失败关闭;`AGC_RELEASE_DRY_RUN` 只预览下一位,不写回、不烧号。一次性播种用 `SEED_ONLY`:基线取「仓库版本 / 各渠道清单 / 旧迁移指针」的最大值,播种本身不烧号。仓库里的 5 个版本文件仍由构建改写,只作构建输入参考,不是事实源。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `commit` 字段,把该提交到本次提交之间触及客户端相关路径的提交标题逐条写进 `notes`(旧协议清单写入 `releaseNotes`,并落盘归档文件 `release-notes.txt`);`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准,缺少上一份 `commit` 时不写摘要。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build` 结束后都会触发 `Genarrative-Notify-Email`;只有正式 OSS 发布成功时,通知正文才附加本次生成的 `latest.json` 首装包 URL,演练、失败或跳过构建时留空,避免把未写入或不完整的对象地址发给收件人。 +dev 调度器发现 revision 变化时,把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy`、`Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`,各条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。这些下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`(两个平台分区发布同一个发号 Job 下发的总版本号),纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。每日 release 调度器按服务端/客户端两个独立 scope 决定下游,固定使用 `DEPLOY_TARGET=release` 和 `CONFIRM_RELEASE_DEPLOY_AGENT=true`:服务端相关路径变化时触发 Full Build,`DATABASE_BACKUP_MODE` 默认 `async`,Stdb/API rollout 模式与审批人由调度参数显式透传;客户端相关路径变化时先经发号 Job 获取统一总号,再以同一个固定 commit 触发 AGC Windows 与 macOS 的 `release` 分区构建,macOS 仍带 `SKIP_IF_SUPERSEDED=true`。三类下游均 `wait: true` 并汇总结果,只有成功 lane 才推进 `.jenkins-last-release-full-revision` 或 `.jenkins-last-release-agc-revision`,失败 lane 下一轮单独补发,不重复已成功的服务端发布。dev 调度器的下游各自判定:AGC Windows Build 采用「客户端相关路径白名单」,Full Build 采用「与线上站点 / 后端无关的路径黑名单」(`docs/`、`.codex/`、`jenkins/`、`apps/ai-game-creator-shell/`、`apps/mobile-shell/`、`apps/desktop-shell/`、`apps/preview-deployer-web/`、`tools/`、根级 `*.md`),改动只要落在黑名单之外就会照常部署,避免漏发线上站点或后端;两条同时被判为跳过时调度管线只推进 revision 状态、不触发任何发布。AGC 客户端版本号不再由渠道各自递增:唯一发号源是 OSS 对象 `agc/global-version.json`,发号收口到 `Genarrative-Agc-Global-Version-Issue`(`disableConcurrentBuilds()` + 写后回读校验;集群未装 `lockable-resources` 插件)。dev / release 两条调度管线与手动发布管线都先调用该 Job 发号,再用归档产物 `agc-global-version.txt` 读取总号并作为 `AGC_RELEASE_VERSION` 透传给客户端构建;统一构建只发一次号供各渠道共用,单渠道热修只把号传给该渠道。显式传入的号低于本渠道当前清单版本时构建失败关闭;`AGC_RELEASE_DRY_RUN` 只预览下一位,不写回、不烧号。一次性播种用 `SEED_ONLY`:基线取「仓库版本 / 各渠道清单 / 旧迁移指针」的最大值,播种本身不烧号。仓库里的 5 个版本文件仍由构建改写,只作构建输入参考,不是事实源。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `commit` 字段,把该提交到本次提交之间触及客户端相关路径的提交标题逐条写进 `notes`(旧协议清单写入 `releaseNotes`,并落盘归档文件 `release-notes.txt`);`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准,缺少上一份 `commit` 时不写摘要。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;release 调度显式覆盖为 `release` 目标并透传备份、rollout、Pingora 和维护退出参数。三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build` 结束后都会触发 `Genarrative-Notify-Email`;只有正式 OSS 发布成功时,通知正文才附加本次生成的 `latest.json` 首装包 URL,演练、失败或跳过构建时留空,避免把未写入或不完整的对象地址发给收件人。 手工发布入口 `Genarrative-Manual-Build-And-Deploy` 的 `DEPLOY_TARGET` 与 AGC 更新渠道是两个独立维度。手工入口必须把 `release` 映射为 `AGC_UPDATE_CHANNEL=release`、把 `development` 映射为 `dev`,并把该参数同时透传给 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`;只传 `AGC_RELEASE_VERSION` 时下游会落回各自默认 `dev`。统一号 `0.1.95` 的 release 包应分别位于 `agc/release-win/0.1.95/` 与 `agc/release-mac/0.1.95/`,渠道清单为对应目录下的 `latest.json`,不存在 `agc/release/` 这一层。补发本轮已烧号的版本时,直接以相同 `AGC_RELEASE_VERSION` 重跑两条 AGC Job,不重新发号。 diff --git a/jenkins/Jenkinsfile.scheduled-release-trigger b/jenkins/Jenkinsfile.scheduled-release-trigger index 044bd0883..28c87b17f 100644 --- a/jenkins/Jenkinsfile.scheduled-release-trigger +++ b/jenkins/Jenkinsfile.scheduled-release-trigger @@ -1,6 +1,6 @@ -// AGC release 每日调度管线:每天 04:00 检查源码分支, -// 只有上一轮 release 调度之后出现客户端相关路径变更时,才用同一个固定 commit -// 触发 AGC Windows/macOS 两个 release 分区构建。 +// release 每日调度管线:每天 04:00 检查源码分支, +// 按独立 scope 触发 release Full Build 与 AGC Windows/macOS, +// 等待并汇总结果;只有对应 lane 成功后才推进该 lane 的成功 revision。 pipeline { agent { label 'linux && genarrative-build' @@ -19,17 +19,25 @@ pipeline { environment { GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git' GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh' + FULL_BUILD_JOB_NAME = 'Genarrative-Full-Build-And-Deploy' AGC_WINDOWS_BUILD_JOB_NAME = 'Genarrative-Agc-Windows-Build' AGC_MACOS_BUILD_JOB_NAME = 'Genarrative-Agc-MacOS-Build' AGC_GLOBAL_VERSION_JOB_NAME = 'Genarrative-Agc-Global-Version-Issue' AGC_GLOBAL_VERSION_ARTIFACT = 'agc-global-version.txt' REVISION_STATE_FILE = '.jenkins-last-release-revision' + FULL_REVISION_STATE_FILE = '.jenkins-last-release-full-revision' + AGC_REVISION_STATE_FILE = '.jenkins-last-release-agc-revision' AGC_SCOPE_CACHE_DIR = '.agc-release-scope-cache' } parameters { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '被检查的源码分支;默认 master') - booleanParam(name: 'FORCE_TRIGGER', defaultValue: false, description: '勾选后忽略版本与路径比较,强制发布 AGC release 渠道') + booleanParam(name: 'FORCE_TRIGGER', defaultValue: false, description: '勾选后忽略版本与路径比较,强制发布 release 服务端与 AGC 双平台') + choice(name: 'DATABASE_BACKUP_MODE', choices: ['async', 'sync', 'skip'], description: 'release 发布前 SpacetimeDB 备份策略;常规使用 async,高风险迁移使用 sync,skip 仅限明确无需备份') + choice(name: 'STDB_API_ROLLOUT_MODE', choices: ['normal', 'pause-after-stdb'], description: 'normal 为自动完成 Stdb → API;pause-after-stdb 在发布 Stdb 后等待审批人放行 API') + string(name: 'STDB_API_ROLLOUT_APPROVERS', defaultValue: '', description: 'pause-after-stdb 必填:允许放行 API 的 Jenkins 用户/组,多个值用逗号分隔') + booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', defaultValue: true, description: 'release API 是否构建、归档并部署 Pingora 影子网关') + booleanParam(name: 'EXIT_MAINTENANCE_MODE_AFTER_COMPLETION', defaultValue: true, description: '完整发布成功后是否退出维护模式;首次验收可暂时关闭') } stages { @@ -50,8 +58,15 @@ pipeline { } script { env.REMOTE_REVISION = readFile('.jenkins-remote-revision').trim() - env.LAST_TRIGGERED_REVISION = fileExists(env.REVISION_STATE_FILE) ? readFile(env.REVISION_STATE_FILE).trim() : '' - env.REVISION_CHANGED = (params.FORCE_TRIGGER || env.LAST_TRIGGERED_REVISION != env.REMOTE_REVISION) ? 'true' : 'false' + def legacyRevision = fileExists(env.REVISION_STATE_FILE) ? readFile(env.REVISION_STATE_FILE).trim() : '' + env.LAST_FULL_REVISION = fileExists(env.FULL_REVISION_STATE_FILE) ? readFile(env.FULL_REVISION_STATE_FILE).trim() : legacyRevision + env.LAST_AGC_REVISION = fileExists(env.AGC_REVISION_STATE_FILE) ? readFile(env.AGC_REVISION_STATE_FILE).trim() : legacyRevision + env.FULL_REVISION_CHANGED = (params.FORCE_TRIGGER || env.LAST_FULL_REVISION != env.REMOTE_REVISION) ? 'true' : 'false' + env.AGC_REVISION_CHANGED = (params.FORCE_TRIGGER || env.LAST_AGC_REVISION != env.REMOTE_REVISION) ? 'true' : 'false' + env.REVISION_CHANGED = (env.FULL_REVISION_CHANGED == 'true' || env.AGC_REVISION_CHANGED == 'true') ? 'true' : 'false' + if (params.STDB_API_ROLLOUT_MODE == 'pause-after-stdb' && !params.STDB_API_ROLLOUT_APPROVERS?.trim()) { + error('pause-after-stdb 必须填写 STDB_API_ROLLOUT_APPROVERS。') + } if (env.REVISION_CHANGED != 'true') { currentBuild.result = 'NOT_BUILT' currentBuild.description = "release 版本未变化,跳过:${env.SOURCE_BRANCH} 仍是 ${env.REMOTE_REVISION.take(12)}" @@ -61,24 +76,21 @@ pipeline { } } - stage('Resolve AGC Release Scope') { + stage('Resolve Release Scope') { when { expression { return env.REVISION_CHANGED == 'true' } } steps { withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) { script { - // 判定失败一律按「需要发布」处理,避免路径过滤阻塞客户端发布。 - def output = 'agc=changed' + // 判定失败一律按「需要发布」处理,避免路径过滤阻塞正式发布。 + def output = 'agc=changed\nfull=changed' try { output = sh(script: '''#!/usr/bin/env bash set -uo pipefail export GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY:?缺少 Git SSH 凭据} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" - previous="$(cat "${REVISION_STATE_FILE}" 2>/dev/null || true)" - if [[ -z "${previous}" ]]; then - printf 'agc=changed\n' - exit 0 - fi + previous_agc="$(cat "${AGC_REVISION_STATE_FILE}" 2>/dev/null || cat "${REVISION_STATE_FILE}" 2>/dev/null || true)" + previous_full="$(cat "${FULL_REVISION_STATE_FILE}" 2>/dev/null || cat "${REVISION_STATE_FILE}" 2>/dev/null || true)" mkdir -p "${AGC_SCOPE_CACHE_DIR}" if [[ ! -d "${AGC_SCOPE_CACHE_DIR}/.git" ]]; then git -C "${AGC_SCOPE_CACHE_DIR}" init --quiet @@ -86,48 +98,121 @@ pipeline { fi refspec="+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" if ! git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags --filter=blob:none origin "${refspec}"; then - git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags origin "${refspec}" || { printf 'agc=changed\n'; exit 0; } + git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags origin "${refspec}" || { printf 'agc=changed\nfull=changed\n'; exit 0; } fi - if ! git -C "${AGC_SCOPE_CACHE_DIR}" cat-file -e "${previous}^{commit}" 2>/dev/null; then - echo "浅取窗口内没有 ${previous},按需要发布处理" >&2 - printf 'agc=changed\n' - exit 0 - fi - changed_paths="$(git -C "${AGC_SCOPE_CACHE_DIR}" diff --name-only "${previous}" "${REMOTE_REVISION}" 2>/dev/null || true)" + changed_paths_for() { + local previous="$1" + if [[ -z "${previous}" ]] || ! git -C "${AGC_SCOPE_CACHE_DIR}" cat-file -e "${previous}^{commit}" 2>/dev/null; then + printf '__changed__\n' + return + fi + git -C "${AGC_SCOPE_CACHE_DIR}" diff --name-only "${previous}" "${REMOTE_REVISION}" 2>/dev/null || printf '__changed__\n' + } + agc_paths="$(changed_paths_for "${previous_agc}")" agc_scope=unchanged - while IFS= read -r changed_path; do - [[ -z "${changed_path}" ]] && continue - case "${changed_path}" in - apps/ai-game-creator-shell/*|packages/*|server-rs/crates/*|plugins/agc-cocos-editor/*|plugins/agc-unity-editor/*|plugins/agc-godot-editor/*|apps/desktop-shell/src-tauri/icons/*|package.json|package-lock.json) - agc_scope=changed - ;; - esac - done <<< "${changed_paths}" - printf 'agc=%s\n' "${agc_scope}" + if [[ "${agc_paths}" == '__changed__' ]]; then + agc_scope=changed + else + while IFS= read -r changed_path; do + [[ -z "${changed_path}" ]] && continue + case "${changed_path}" in + apps/ai-game-creator-shell/*|packages/*|server-rs/crates/*|plugins/agc-cocos-editor/*|plugins/agc-unity-editor/*|plugins/agc-godot-editor/*|apps/desktop-shell/src-tauri/icons/*|package.json|package-lock.json) + agc_scope=changed + ;; + esac + done <<< "${agc_paths}" + fi + full_paths="$(changed_paths_for "${previous_full}")" + full_scope=unchanged + if [[ "${full_paths}" == '__changed__' ]]; then + full_scope=changed + else + while IFS= read -r changed_path; do + [[ -z "${changed_path}" ]] && continue + case "${changed_path}" in + docs/*|.codex/*|jenkins/*|apps/ai-game-creator-shell/*|apps/mobile-shell/*|apps/desktop-shell/*|apps/preview-deployer-web/*|tools/*|*.md) + ;; + *) + full_scope=changed + ;; + esac + done <<< "${full_paths}" + fi + printf 'agc=%s\nfull=%s\n' "${agc_scope}" "${full_scope}" ''', returnStdout: true).trim() } catch (error) { echo "release 发布范围判定失败,按需要发布处理:${error}" - output = 'agc=changed' + output = 'agc=changed\nfull=changed' } def values = output.split('\n').collect { it.trim() }.findAll { it } - def entry = values.find { it.startsWith('agc=') } - def value = entry == null ? '' : entry.split('=')[1] - env.AGC_RELEASE_SCOPE = (value == 'unchanged') ? 'unchanged' : 'changed' - echo "release 发布范围:AGC=${env.AGC_RELEASE_SCOPE}(上一轮已触发 revision=${env.LAST_TRIGGERED_REVISION ?: '无'})" + def readScope = { String key -> + def entry = values.find { it.startsWith(key + '=') } + def value = entry == null ? '' : entry.split('=')[1] + return (value == 'unchanged') ? 'unchanged' : 'changed' + } + env.AGC_RELEASE_SCOPE = readScope('agc') + env.FULL_BUILD_SCOPE = readScope('full') + echo "release 发布范围:AGC=${env.AGC_RELEASE_SCOPE} FullBuild=${env.FULL_BUILD_SCOPE}(AGC=${env.LAST_AGC_REVISION ?: '无'},Full=${env.LAST_FULL_REVISION ?: '无'})" } } } } - stage('Trigger AGC Release Builds') { + stage('Trigger Release Downstreams') { when { expression { return env.REVISION_CHANGED == 'true' } } steps { script { def pinnedRevision = env.REMOTE_REVISION - def agcTriggered = false - if (params.FORCE_TRIGGER || env.AGC_RELEASE_SCOPE != 'unchanged') { + def fullTriggered = params.FORCE_TRIGGER || env.FULL_BUILD_SCOPE != 'unchanged' + def agcTriggered = params.FORCE_TRIGGER || env.AGC_RELEASE_SCOPE != 'unchanged' + def fullParameters = [ + string(name: 'SOURCE_BRANCH', value: env.SOURCE_BRANCH), + string(name: 'COMMIT_HASH', value: pinnedRevision), + string(name: 'DEPLOY_TARGET', value: 'release'), + booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: true), + string(name: 'DATABASE_BACKUP_MODE', value: params.DATABASE_BACKUP_MODE), + string(name: 'STDB_API_ROLLOUT_MODE', value: params.STDB_API_ROLLOUT_MODE), + string(name: 'STDB_API_ROLLOUT_APPROVERS', value: params.STDB_API_ROLLOUT_APPROVERS?.trim() ?: ''), + booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', value: params.INCLUDE_PINGORA_GATEWAY), + booleanParam(name: 'EXIT_MAINTENANCE_MODE_AFTER_COMPLETION', value: params.EXIT_MAINTENANCE_MODE_AFTER_COMPLETION), + ] + if (!fullTriggered) { + echo "上一轮 release 调度后没有服务端相关变更,跳过 ${env.FULL_BUILD_JOB_NAME}" + } + if (!agcTriggered) { + echo "上一轮 release 调度后没有 AGC 客户端相关变更,跳过 ${env.AGC_WINDOWS_BUILD_JOB_NAME} 与 ${env.AGC_MACOS_BUILD_JOB_NAME}" + } + def outcome = [:] + def runDownstream = { String jobName, List jobParameters, int timeoutMinutes -> + try { + timeout(time: timeoutMinutes, unit: 'MINUTES') { + def run = build job: jobName, + wait: true, + propagate: false, + parameters: jobParameters + outcome[jobName] = "#${run.number}:${run.result}" + if (run.result != 'SUCCESS') { + error("${jobName} 结果=${run.result}") + } + } + } catch (err) { + if (!outcome[jobName]) { + outcome[jobName] = "异常:${err.message}" + } + echo "${jobName} 等待失败: ${err.message}" + throw err + } + } + def branches = [:] + def fullTimeout = params.STDB_API_ROLLOUT_MODE == 'pause-after-stdb' ? 300 : 150 + if (fullTriggered) { + branches[env.FULL_BUILD_JOB_NAME] = { + runDownstream(env.FULL_BUILD_JOB_NAME, fullParameters, fullTimeout) + } + } + if (agcTriggered) { // Windows 与 macOS 共用一个总号;发号 Job 是唯一发号源。 def versionRun = build job: env.AGC_GLOBAL_VERSION_JOB_NAME, wait: true, @@ -152,22 +237,45 @@ pipeline { string(name: 'AGC_UPDATE_CHANNEL', value: 'release'), string(name: 'AGC_RELEASE_VERSION', value: env.AGC_GLOBAL_VERSION), ] - build job: env.AGC_WINDOWS_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcParameters // macOS 节点是办公机:离线期间排队的旧构建在节点回来后必须自行让位。 def agcMacosParameters = agcParameters + [ booleanParam(name: 'SKIP_IF_SUPERSEDED', value: true), ] - build job: env.AGC_MACOS_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcMacosParameters - agcTriggered = true - } else { - echo "上一轮 release 调度后没有 AGC 客户端相关变更,跳过 ${env.AGC_WINDOWS_BUILD_JOB_NAME} 与 ${env.AGC_MACOS_BUILD_JOB_NAME};需要强制发布时勾选 FORCE_TRIGGER" + branches[env.AGC_WINDOWS_BUILD_JOB_NAME] = { + runDownstream(env.AGC_WINDOWS_BUILD_JOB_NAME, agcParameters, 120) + } + branches[env.AGC_MACOS_BUILD_JOB_NAME] = { + runDownstream(env.AGC_MACOS_BUILD_JOB_NAME, agcMacosParameters, 120) + } } - writeFile file: env.REVISION_STATE_FILE, text: pinnedRevision + if (!branches.isEmpty()) { + try { + parallel branches + } catch (err) { + echo "release 并行下游存在失败分支: ${err.message}" + } + } + def fullResult = outcome[env.FULL_BUILD_JOB_NAME] + def windowsResult = outcome[env.AGC_WINDOWS_BUILD_JOB_NAME] + def macosResult = outcome[env.AGC_MACOS_BUILD_JOB_NAME] + def fullSucceeded = !fullTriggered || (fullResult != null && fullResult.endsWith(':SUCCESS')) + def agcSucceeded = !agcTriggered || (windowsResult != null && windowsResult.endsWith(':SUCCESS') && macosResult != null && macosResult.endsWith(':SUCCESS')) + if (fullSucceeded) { + writeFile file: env.FULL_REVISION_STATE_FILE, text: pinnedRevision + } + if (agcSucceeded) { + writeFile file: env.AGC_REVISION_STATE_FILE, text: pinnedRevision + } + if (fullSucceeded && agcSucceeded) { + writeFile file: env.REVISION_STATE_FILE, text: pinnedRevision + } + def summary = outcome.collect { jobName, result -> "${jobName}=${result}" }.join(' ') def target = "${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}" - currentBuild.description = agcTriggered - ? "已触发 AGC release:${env.AGC_WINDOWS_BUILD_JOB_NAME} 与 ${env.AGC_MACOS_BUILD_JOB_NAME} ${target}" - : "本轮没有 AGC 客户端相关变更,未触发 release 发布:${target}" + currentBuild.description = "release 调度 ${target};FullBuild=${fullTriggered ? 'triggered' : 'unchanged'};AGC=${agcTriggered ? 'triggered' : 'unchanged'}${summary ? ';' + summary : ''}" echo currentBuild.description + if (!fullSucceeded || !agcSucceeded) { + error("release 下游未全部成功: ${summary}") + } } } } @@ -175,7 +283,7 @@ pipeline { post { always { - echo "release 调度结束: branch=${env.SOURCE_BRANCH} revision=${env.REMOTE_REVISION ?: ''} changed=${env.REVISION_CHANGED ?: ''} agcScope=${env.AGC_RELEASE_SCOPE ?: ''}" + echo "release 调度结束: branch=${env.SOURCE_BRANCH} revision=${env.REMOTE_REVISION ?: ''} changed=${env.REVISION_CHANGED ?: ''} agcScope=${env.AGC_RELEASE_SCOPE ?: ''} fullScope=${env.FULL_BUILD_SCOPE ?: ''}" } } } diff --git a/jenkins/scheduled-release-trigger-job-config.xml b/jenkins/scheduled-release-trigger-job-config.xml index 6baaa851b..8a8afe6be 100644 --- a/jenkins/scheduled-release-trigger-job-config.xml +++ b/jenkins/scheduled-release-trigger-job-config.xml @@ -1,7 +1,7 @@ - 每天 04:00 检查源码分支版本(cron 定义在 Jenkinsfile 内):revision 变化且出现客户端相关路径变更时,先经 Genarrative-Agc-Global-Version-Issue 发号,再用同一个固定 commit 与总版本号触发 AGC Windows、macOS 两个 release 分区构建;macOS 额外带 SKIP_IF_SUPERSEDED。下游 Job 一律不自带触发器。 + 每天 04:00 检查源码分支版本(cron 定义在 Jenkinsfile 内):revision 变化且出现服务端相关路径时触发 release Full Build,出现客户端相关路径时经 Genarrative-Agc-Global-Version-Issue 发号并触发 AGC Windows、macOS;调度器等待并汇总三类下游结果,按成功 lane 推进 revision,失败 lane 下一轮补发。下游 Job 一律不自带触发器。 false @@ -14,9 +14,46 @@ FORCE_TRIGGER - 勾选后忽略版本与路径比较,强制发布 AGC release 渠道 + 勾选后忽略版本与路径比较,强制发布 release 服务端与 AGC 双平台 false + + DATABASE_BACKUP_MODE + release 发布前 SpacetimeDB 备份策略;常规使用 async,高风险迁移使用 sync,skip 仅限明确无需备份 + + + async + sync + skip + + + + + STDB_API_ROLLOUT_MODE + normal 为自动完成 Stdb → API;pause-after-stdb 在发布 Stdb 后等待审批人放行 API + + + normal + pause-after-stdb + + + + + STDB_API_ROLLOUT_APPROVERS + pause-after-stdb 必填:允许放行 API 的 Jenkins 用户/组,多个值用逗号分隔 + + true + + + INCLUDE_PINGORA_GATEWAY + release API 是否构建、归档并部署 Pingora 影子网关 + true + + + EXIT_MAINTENANCE_MODE_AFTER_COMPLETION + 完整发布成功后是否退出维护模式;首次验收可暂时关闭 + true + diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 220d5f0ee..5c0f8af18 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -2602,12 +2602,13 @@ function assertAiGameCreatorShellUserDevBoundary() { { url: 'https://*/api/*' }, { url: 'http://localhost:*/*' }, { url: 'http://127.0.0.1:*/*' }, + { url: 'https://*.aliyuncs.com/*' }, ]) ) { throw new Error( - // 更新清单与安装包下载已改由 tauri-plugin-updater 在原生侧完成, - // 不再需要为 webview 的 http 插件放行 OSS 域名。 - 'AI game creator native HTTP scope must match the release, dev, custom HTTPS, and loopback API boundary', + // 更新清单与安装包下载由 tauri-plugin-updater 在原生侧完成; + // 封面与截图仍通过 webview 的 http 插件向凭证指定的 OSS 地址直传。 + 'AI game creator native HTTP scope must match the release, dev, custom HTTPS, loopback API, and OSS media upload boundary', ); } @@ -2647,7 +2648,7 @@ function assertAiGameCreatorShellUserDevBoundary() { } for (const snippet of [ "await invoke(\n 'activate_local_game_preview'", - '已切换到客户端运行视图', + '已载入客户端运行视图', ]) { if (!sourceIncludesSnippet(aiGameCreatorShellAppSource, snippet)) { throw new Error( @@ -2665,6 +2666,14 @@ function assertAiGameCreatorShellUserDevBoundary() { ); } } + /* + * 预览**不自动**开系统浏览器:启动与切换运行视图只走内置画面,两条老路必须一直挡着 + * (App.tsx 里的 `openPreviewInExternalBrowser` / 前端直调 `open_local_game_preview` / + * Rust 侧 `.open_url(`)。 + * + * 用户主动点「在浏览器打开」是另一回事:它走 opener 插件的 `openUrl`,入口只有顶栏那一枚 + * 按钮(见下面那条正向断言)。别把这条负向守卫推广成「任何地方都不许出现 openUrl」。 + */ if ( aiGameCreatorShellAppSource.includes('openPreviewInExternalBrowser') || aiGameCreatorShellAppSource.includes('open_local_game_preview') || @@ -2674,6 +2683,31 @@ function assertAiGameCreatorShellUserDevBoundary() { 'AI game creator preview must not invoke the external browser', ); } + for (const snippet of [ + "'@tauri-apps/plugin-opener'", + '在浏览器打开', + 'await openUrl(embeddedPreviewUrl)', + ]) { + if ( + !sourceIncludesSnippet(aiGameCreatorProjectDevelopmentSource, snippet) + ) { + throw new Error( + `AI game creator in-browser preview entry drifted: missing ${snippet}`, + ); + } + } + /* + * 上面那条负向守卫只盯着 App.tsx 里的两条老路,而真正的浏览器出口在运行页视图里。 + * 这里再补一条「调用点只有一个」的判据:视图里冒出第二个 `openUrl(`(自动跳浏览器、 + * 或者拿它去开任意地址)时必须先显式改这条守卫,而不是顺手加一行。 + */ + const openUrlCallCount = + aiGameCreatorProjectDevelopmentSource.split('openUrl(').length - 1; + if (openUrlCallCount !== 1) { + throw new Error( + `AI game creator must keep exactly one user-clicked browser-open call in the run view (found ${openUrlCallCount})`, + ); + } if ( aiGameCreatorShellTauriSource.includes('fn open_developer_window(') || aiGameCreatorShellTauriSource.includes( diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 6bc31b5b6..a25f85cc3 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -7761,7 +7761,7 @@ for (const [file, content] of [ if (/\btriggers\s*\{/u.test(content) || content.includes('cron(')) { failed = true; console.error( - `[check:production-ops] ${file} 不得自带定时触发器;版本检查与触发必须由 Genarrative-Scheduled-Revision-Trigger 统一负责。`, + `[check:production-ops] ${file} 不得自带定时触发器;版本检查与触发必须由两条定时调度管线统一负责。`, ); } } @@ -7908,6 +7908,10 @@ for (const [snippet, reason] of [ ['disableConcurrentBuilds()', '必须禁止 release 调度器并发触发'], ['skipDefaultCheckout(true)', 'release 调度器不得依赖本地 SCM 工作区'], ['git ls-remote', 'release 调度器必须直接从远端解析分支版本'], + [ + "FULL_BUILD_JOB_NAME = 'Genarrative-Full-Build-And-Deploy'", + '必须声明 release Full Build 目标 Job', + ], [ "AGC_WINDOWS_BUILD_JOB_NAME = 'Genarrative-Agc-Windows-Build'", '必须声明 AGC Windows release 构建目标 Job', @@ -7916,6 +7920,19 @@ for (const [snippet, reason] of [ "AGC_MACOS_BUILD_JOB_NAME = 'Genarrative-Agc-MacOS-Build'", '必须声明 AGC macOS release 构建目标 Job', ], + [ + "FULL_REVISION_STATE_FILE = '.jenkins-last-release-full-revision'", + '必须独立记录 Full Build 成功 revision', + ], + [ + "AGC_REVISION_STATE_FILE = '.jenkins-last-release-agc-revision'", + '必须独立记录 AGC 双平台成功 revision', + ], + ['full=changed', 'release 调度器必须计算服务端 Full Build scope'], + [ + "env.FULL_BUILD_SCOPE = readScope('full')", + 'release 调度器必须解析 Full Build scope', + ], [ "string(name: 'AGC_CHANNEL', value: 'release-unified')", 'release 调度器必须先通过发号 Job 获取统一总版本号', @@ -7925,27 +7942,64 @@ for (const [snippet, reason] of [ 'release 调度器必须把 Windows 与 macOS 都发到 release 分区', ], [ - 'build job: env.AGC_WINDOWS_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcParameters', + "string(name: 'DEPLOY_TARGET', value: 'release')", + 'release Full Build 必须固定部署到 release', + ], + [ + "booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: true)", + 'release Full Build 必须确认使用独立 release 部署 agent', + ], + [ + "string(name: 'DATABASE_BACKUP_MODE', value: params.DATABASE_BACKUP_MODE)", + 'release Full Build 必须显式透传数据库备份策略', + ], + [ + "string(name: 'STDB_API_ROLLOUT_MODE', value: params.STDB_API_ROLLOUT_MODE)", + 'release Full Build 必须显式透传 Stdb/API rollout 策略', + ], + [ + 'def runDownstream = { String jobName, List jobParameters, int timeoutMinutes ->', + 'release 调度器必须统一等待并收集三个下游结果', + ], + [ + 'build job: jobName,\n wait: true,\n propagate: false,', + 'release 下游必须等待完成并保留每个 Job 的结果', + ], + [ + 'branches[env.FULL_BUILD_JOB_NAME] = {', + 'release 调度器必须触发 Full Build', + ], + [ + 'branches[env.AGC_WINDOWS_BUILD_JOB_NAME] = {', 'release 调度器必须触发 AGC Windows release 构建', ], [ - 'build job: env.AGC_MACOS_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcMacosParameters', + 'branches[env.AGC_MACOS_BUILD_JOB_NAME] = {', 'release 调度器必须触发 AGC macOS release 构建', ], [ "booleanParam(name: 'SKIP_IF_SUPERSEDED', value: true)", 'release 调度器必须让 macOS 节点恢复后跳过过期排队构建', ], + [ + 'if (fullSucceeded) {', + '只有 Full Build 成功后才能推进 Full Build revision', + ], + ['if (agcSucceeded) {', '只有 AGC 双平台成功后才能推进 AGC revision'], + [ + 'error("release 下游未全部成功: ${summary}")', + '任一 release 下游失败时调度器必须失败', + ], ]) { if (!scheduledReleaseTriggerContent.includes(snippet)) { failed = true; console.error(`[check:production-ops] release 调度管线${reason}。`); } } -if (scheduledReleaseTriggerContent.includes('FULL_BUILD_JOB_NAME')) { +if (scheduledReleaseTriggerContent.includes('wait: false')) { failed = true; console.error( - '[check:production-ops] release 调度管线只负责 AGC release 客户端构建,不得触发 Full Build。', + '[check:production-ops] release 调度管线不得 fire-and-forget,必须等待并汇总 Full Build 与 AGC 结果。', ); } if ( diff --git a/server-rs/crates/platform-oss/src/client_downloads.rs b/server-rs/crates/platform-oss/src/client_downloads.rs index d8476fc44..b9bc5f0aa 100644 --- a/server-rs/crates/platform-oss/src/client_downloads.rs +++ b/server-rs/crates/platform-oss/src/client_downloads.rs @@ -304,7 +304,8 @@ fn has_safe_percent_encoding(file_name: &str) -> bool { let Ok(byte) = u8::from_str_radix(hex, 16) else { return false; }; - if byte <= b' ' || matches!(byte, b'/' | b'\\' | 127) { + // 渠道安装身份可以包含空格,OSS 清单中的文件名会将空格编码为 `%20`。 + if byte < b' ' || matches!(byte, b'/' | b'\\' | 127) { return false; } remaining = &encoded[2..]; @@ -541,6 +542,29 @@ mod tests { } } + #[test] + fn percent_encoded_spaces_in_release_installer_names_are_allowed() { + let version = "0.1.110"; + let installer = download_url( + DownloadPlatform::Windows, + version, + "%E9%99%B6%E6%B3%A5%E5%84%BF%20Release_0.1.110_x64-setup.exe", + ) + .replace("/dev-win/", "/release-win/"); + let manifest = manifest_with_downloads( + version, + json!({ + "windows-x86_64": { "url": installer } + }), + ); + + let parsed = + super::parse_manifest(&manifest, DownloadPlatform::Windows, "release").unwrap(); + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].download_url, installer); + } + #[test] fn macos_requires_explicit_dmg_metadata_and_preserves_available_architectures() { let updater_only = json!({