diff --git a/.gitignore b/.gitignore index 88a842855..652820e39 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,12 @@ temp*build*/ /apps/ai-game-creator-shell/src-tauri/resources/node-runtime/ /apps/ai-game-creator-shell/src-tauri/resources/claude-agent/ /apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/ +/apps/ai-game-creator-shell/src-tauri/resources/plugins-staging-*/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/*-staging-*/ +/apps/ai-game-creator-shell/src-tauri/resources/plugins-backup-*/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/*-backup-*/ +/apps/ai-game-creator-shell/src-tauri/resources/claude-agent-staging-*/ +/apps/ai-game-creator-shell/src-tauri/resources/claude-agent-backup-*/ /apps/ai-game-creator-shell/.llm-drafts/ /apps/ai-game-creator-shell/game-creator.config.local.json /apps/mobile-shell/.expo/ diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx index 7916e60e3..1e185e9a5 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx @@ -16,7 +16,10 @@ import { getAdminProjectSnapshotChannels, listAdminProjectSnapshots, } from '../api/adminApiClient'; -import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes'; +import type { + AdminProjectSnapshotEntry, + AdminProjectSnapshotListResponse, +} from '../api/adminApiTypes'; import { AdminProjectSnapshotsPage } from './AdminProjectSnapshotsPage'; vi.mock('../api/adminApiClient', async () => ({ @@ -58,6 +61,241 @@ afterEach(() => { vi.useRealTimers(); }); +test('全量读取后按同步时间和身份排序,本地分页并支持刷新和恢复默认', async () => { + const olderProjects = Array.from({ length: 22 }, (_, index) => ({ + ...entry, + projectId: `older-${index}`, + projectName: `旧项目 ${index}`, + syncedAtMs: entry.syncedAtMs + index, + })); + const tiedProjects = [ + { ...entry, userId: 'user-2', projectId: 'p-1', projectName: '同秒乙' }, + { ...entry, userId: 'user-1', projectId: 'p-2', projectName: '同秒甲二' }, + { ...entry, userId: 'user-1', projectId: 'p-1', projectName: '同秒甲一' }, + ].map((project) => ({ ...project, syncedAtMs: entry.syncedAtMs + 100 })); + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' }) + .mockResolvedValueOnce({ items: olderProjects, nextCursor: 'scan-2' }) + // 后端有界扫描可能返回带下一游标的空页,不能提前结束。 + .mockResolvedValueOnce({ items: [], nextCursor: 'scan-3' }) + .mockResolvedValueOnce({ + items: [ + ...tiedProjects, + { + ...olderProjects[0]!, + projectName: '更新项目', + syncedAtMs: entry.syncedAtMs + 200, + }, + ], + nextCursor: null, + }); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + await screen.findByRole('button', { name: '恢复默认顺序' }); + const rowNames = () => + screen + .getAllByRole('row') + .map((row) => row.querySelector('td strong')?.textContent) + .filter(Boolean); + expect(rowNames().slice(0, 4)).toEqual([ + '更新项目', + '同秒甲一', + '同秒甲二', + '同秒乙', + ]); + expect(rowNames()).toHaveLength(20); + expect(screen.getByRole('navigation').textContent).toContain('共 25 个'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 4, + 'token', + { cursor: 'scan-3', limit: 100, channel: 'dev' }, + expect.any(AbortSignal), + ); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + expect(rowNames()).toHaveLength(5); + expect(screen.getByRole('navigation').textContent).toContain('第 2 页'); + fireEvent.click(screen.getByRole('button', { name: '上一页' })); + expect(rowNames()[0]).toBe('更新项目'); + fireEvent.change(screen.getByLabelText('每页条数'), { + target: { value: '50' }, + }); + expect(rowNames()).toHaveLength(25); + expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(4); + + vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({ + items: [{ ...entry, projectName: '刷新后的项目' }], + nextCursor: null, + }); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + await screen.findByText('刷新后的项目'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 5, + 'token', + { cursor: null, limit: 100, channel: 'dev' }, + expect.any(AbortSignal), + ); + fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' })); + await screen.findByText('三消工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 6, + 'token', + { cursor: null, limit: 50, channel: 'dev' }, + expect.any(AbortSignal), + ); +}); + +test('恢复默认顺序失败保留排序分页,重试成功后切换远端第一页及游标', async () => { + const projects = Array.from({ length: 45 }, (_, index) => ({ + ...entry, + projectId: `sorted-${index}`, + projectName: `排序项目 ${index}`, + syncedAtMs: entry.syncedAtMs - index, + })); + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: null }) + .mockResolvedValueOnce({ items: projects, nextCursor: null }) + .mockRejectedValueOnce(new Error('恢复失败')) + .mockResolvedValueOnce({ + items: [{ ...entry, projectName: '默认第一页' }], + nextCursor: 'default-next', + }) + .mockResolvedValueOnce({ + items: [{ ...entry, projectName: '默认第二页' }], + nextCursor: null, + }); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + await screen.findByText('排序项目 0'); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' })); + await screen.findByText('恢复失败'); + expect(screen.getByText('排序项目 20')).toBeTruthy(); + expect(screen.getByRole('navigation').textContent).toContain('第 2 页'); + expect(screen.getByRole('navigation').textContent).toContain('共 45 个'); + expect(screen.getByRole('button', { name: '恢复默认顺序' })).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + expect(screen.getByText('排序项目 40')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '上一页' })); + expect(screen.getByText('排序项目 20')).toBeTruthy(); + expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(3); + + fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' })); + await screen.findByText('默认第一页'); + expect(screen.getByRole('navigation').textContent).toContain('第 1 页'); + expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy(); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 4, + 'token', + { cursor: null, limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await screen.findByText('默认第二页'); + expect(screen.getByRole('navigation').textContent).toContain('第 2 页'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 5, + 'token', + { cursor: 'default-next', limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); +}); + +test('全量排序中途失败不发布部分结果,重试刷新失败保留完整排序', async () => { + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' }) + .mockResolvedValueOnce({ + items: [{ ...entry, projectName: '部分读取项目' }], + nextCursor: 'scan-next', + }) + .mockRejectedValueOnce(new Error('全量读取失败')); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + await screen.findByText('全量读取失败'); + expect(screen.getByText('三消工程')).toBeTruthy(); + expect(screen.queryByText('部分读取项目')).toBeNull(); + expect(screen.queryByRole('button', { name: '恢复默认顺序' })).toBeNull(); + expect( + screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'), + ).toBe(false); + + vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({ + items: [{ ...entry, projectName: '完整排序项目' }], + nextCursor: null, + }); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + await screen.findByText('完整排序项目'); + vi.mocked(listAdminProjectSnapshots).mockRejectedValueOnce( + new Error('刷新读取失败'), + ); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + await screen.findByText('刷新读取失败'); + expect(screen.getByText('完整排序项目')).toBeTruthy(); + expect(screen.getByRole('navigation').textContent).toContain('共 1 个'); +}); + +test('取消或切换渠道时中断全量读取,忽略随后到达的旧响应', async () => { + vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({ + defaultChannel: 'dev', + channels: ['dev', 'release'], + }); + let finishCancelled!: (value: AdminProjectSnapshotListResponse) => void; + let finishOldChannel!: (value: AdminProjectSnapshotListResponse) => void; + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: null }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishCancelled = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishOldChannel = resolve; + }), + ) + .mockResolvedValueOnce({ + items: [{ ...entry, channel: 'release', projectName: '正式渠道项目' }], + nextCursor: null, + }); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + const cancelledSignal = vi.mocked(listAdminProjectSnapshots).mock + .calls[1]![2]!; + fireEvent.click(screen.getByRole('button', { name: '取消排序' })); + expect(cancelledSignal.aborted).toBe(true); + await act(async () => { + finishCancelled({ + items: [{ ...entry, projectName: '已取消项目' }], + nextCursor: null, + }); + }); + expect(screen.getByText('三消工程')).toBeTruthy(); + expect(screen.queryByText('已取消项目')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + const oldChannelSignal = vi.mocked(listAdminProjectSnapshots).mock + .calls[2]![2]!; + fireEvent.change(screen.getByLabelText('项目工程渠道'), { + target: { value: 'release' }, + }); + await screen.findByText('正式渠道项目'); + expect(oldChannelSignal.aborted).toBe(true); + await act(async () => { + finishOldChannel({ + items: [{ ...entry, projectName: '旧渠道项目' }], + nextCursor: null, + }); + }); + expect(screen.getByText('正式渠道项目')).toBeTruthy(); + expect(screen.queryByText('旧渠道项目')).toBeNull(); + expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy(); +}); + test('按项目展示完整性并限制未完成工程下载', async () => { vi.mocked(listAdminProjectSnapshots).mockResolvedValue({ items: [ diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx index 026adec6b..b8c6d7b3a 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx @@ -10,7 +10,7 @@ import { AdminPagination, AdminStatusPill, } from '@genarrative/shared/components'; -import { Download, RefreshCcw, X } from 'lucide-react'; +import { ArrowDownWideNarrow, Download, RefreshCcw, X } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { @@ -50,6 +50,9 @@ const snapshotStatuses: Record< const DEFAULT_PAGE_SIZE = 20; const PAGE_SIZE_OPTIONS = [20, 50, 100]; +const MAX_SORT_PAGES = 200; +const MAX_SORT_PROJECTS = 10_000; +const SORT_TIMEOUT_MS = 60_000; export function AdminProjectSnapshotsPage({ token, @@ -64,6 +67,9 @@ export function AdminProjectSnapshotsPage({ const [channelOptions, setChannelOptions] = useState([]); const [isLoading, setIsLoading] = useState(false); const [hasLoaded, setHasLoaded] = useState(false); + const [isSorted, setIsSorted] = useState(false); + const [isSorting, setIsSorting] = useState(false); + const [sortProgress, setSortProgress] = useState(0); const [errorMessage, setErrorMessage] = useState(''); const [downloadingKey, setDownloadingKey] = useState(null); const listController = useRef(null); @@ -71,9 +77,23 @@ export function AdminProjectSnapshotsPage({ // 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回, // 因此按页记录已取得的游标,翻页只在这些游标之间移动。 const pageCursors = useRef<(string | null)[]>([null]); + // 全量读取完成后才发布;分页只切片本次读到的完整集合。 + const sortedItems = useRef(null); const loadPage = useCallback( - async (cursor: string | null, limit: number, page: number) => { + async ( + cursor: string | null, + limit: number, + page: number, + restoreDefault = false, + ) => { + if (!restoreDefault && sortedItems.current !== null) { + setItems(sortedItems.current.slice((page - 1) * limit, page * limit)); + setPageIndex(page); + setHasLoaded(true); + setErrorMessage(''); + return; + } listController.current?.abort(); const controller = new AbortController(); listController.current = controller; @@ -86,6 +106,12 @@ export function AdminProjectSnapshotsPage({ controller.signal, ); if (controller.signal.aborted) return; + // 远端第一页读取成功后再切换模式,失败时保留完整排序和分页。 + if (restoreDefault) { + sortedItems.current = null; + setIsSorted(false); + pageCursors.current = [null]; + } setItems(response.items); setNextCursor(response.nextCursor); setPageIndex(page); @@ -126,8 +152,16 @@ export function AdminProjectSnapshotsPage({ return () => controller.abort(); }, [token, onUnauthorized]); + useEffect(() => { + sortedItems.current = null; + setIsSorted(false); + }, [token, channel]); + useEffect(() => { if (channel === null) return undefined; + setIsSorting(false); + setIsLoading(false); + setSortProgress(0); pageCursors.current = [null]; setItems([]); setNextCursor(null); @@ -144,6 +178,10 @@ export function AdminProjectSnapshotsPage({ }, [loadPage, pageSize, channel]); function goToNextPage() { + if (sortedItems.current !== null) { + void loadPage(null, pageSize, pageIndex + 1); + return; + } if (!nextCursor) return; pageCursors.current[pageIndex] = nextCursor; void loadPage(nextCursor, pageSize, pageIndex + 1); @@ -159,6 +197,10 @@ export function AdminProjectSnapshotsPage({ } function refreshCurrentPage() { + if (sortedItems.current !== null) { + void sortAllProjects(); + return; + } void loadPage( pageCursors.current[pageIndex - 1] ?? null, pageSize, @@ -166,6 +208,90 @@ export function AdminProjectSnapshotsPage({ ); } + async function sortAllProjects() { + if (channel === null) return; + listController.current?.abort(); + const controller = new AbortController(); + listController.current = controller; + setIsLoading(true); + setIsSorting(true); + setSortProgress(0); + setErrorMessage(''); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, SORT_TIMEOUT_MS); + try { + const projects = new Map(); + const seenCursors = new Set(); + let cursor: string | null = null; + for (let page = 0; page < MAX_SORT_PAGES; page += 1) { + const response = await listAdminProjectSnapshots( + token, + { cursor, limit: 100, channel }, + controller.signal, + ); + if (controller.signal.aborted) return; + for (const project of response.items) { + const key = snapshotKey(project); + const previous = projects.get(key); + if (!previous || project.syncedAtMs >= previous.syncedAtMs) { + projects.set(key, project); + } + } + if (projects.size > MAX_SORT_PROJECTS) { + throw new Error('项目超过 10,000 个,无法完成全量排序'); + } + setSortProgress(projects.size); + if (!response.nextCursor) { + const result = [...projects.values()].sort( + (left, right) => + right.syncedAtMs - left.syncedAtMs || + compareIds(left.userId, right.userId) || + compareIds(left.projectId, right.projectId), + ); + sortedItems.current = result; + setIsSorted(true); + setItems(result.slice(0, pageSize)); + setPageIndex(1); + setNextCursor(null); + setHasLoaded(true); + return; + } + if (seenCursors.has(response.nextCursor)) { + throw new Error('项目列表游标重复,无法完成全量排序,请重试'); + } + seenCursors.add(response.nextCursor); + cursor = response.nextCursor; + } + throw new Error('项目读取超过 200 页,无法完成全量排序'); + } catch (error: unknown) { + if (!controller.signal.aborted) { + handlePageError(error, onUnauthorized, setErrorMessage); + } + } finally { + clearTimeout(timeout); + if (listController.current === controller) { + listController.current = null; + setIsLoading(false); + setIsSorting(false); + if (timedOut) setErrorMessage('全量项目读取超时,请重试'); + } + } + } + + function cancelSorting() { + listController.current?.abort(); + listController.current = null; + setIsLoading(false); + setIsSorting(false); + } + + function restoreDefaultOrder() { + void loadPage(null, pageSize, 1, true); + } + async function downloadProject(entry: AdminProjectSnapshotEntry) { if (downloadController.current || entry.status === 'partial') return; const controller = new AbortController(); @@ -236,6 +362,32 @@ export function AdminProjectSnapshotsPage({ ))} + void sortAllProjects() + } + > + + {isSorting ? ( + <> + + 正在读取全部项目,已读取 {sortProgress} 个 + + + + + ) : null} 第 {pageIndex} 页 {items.length ? `,本页 ${items.length} 个项目` : ''} + {isSorted + ? `,共 ${sortedItems.current?.length ?? 0} 个,按同步时间从新到旧` + : ''} } pageSize={{ @@ -279,7 +434,12 @@ export function AdminProjectSnapshotsPage({ }} busy={isLoading} previous={{ disabled: pageIndex <= 1, onClick: goToPreviousPage }} - next={{ disabled: !nextCursor, onClick: goToNextPage }} + next={{ + disabled: isSorted + ? pageIndex * pageSize >= (sortedItems.current?.length ?? 0) + : !nextCursor, + onClick: goToNextPage, + }} /> ) : null } @@ -375,6 +535,10 @@ function snapshotKey(entry: AdminProjectSnapshotEntry) { return `${entry.userId}/${entry.projectId}`; } +function compareIds(left: string, right: string) { + return left < right ? -1 : left > right ? 1 : 0; +} + function formatBytes(bytes: number) { const units = ['B', 'KiB', 'MiB', 'GiB']; const unit = Math.min( diff --git a/apps/admin-web/vite.config.ts b/apps/admin-web/vite.config.ts index daebcda64..a8fa7e8bc 100644 --- a/apps/admin-web/vite.config.ts +++ b/apps/admin-web/vite.config.ts @@ -56,7 +56,9 @@ export default defineConfig(({ command, mode }) => { build: { outDir: 'dist', emptyOutDir: true, - chunkSizeWarningLimit: 600, + // 中文注释:后台单页 chunk 已增长到 600.23 kB;沿用 2026-05-01 把 500 提到 600 的同一处理口径 + // 提到 700,先让直推 master 的构建门禁恢复可过,真正的按路由拆分另行排期。 + chunkSizeWarningLimit: 700, }, }; }); diff --git a/apps/ai-game-creator-shell/.taurignore b/apps/ai-game-creator-shell/.taurignore deleted file mode 100644 index 8ec126556..000000000 --- a/apps/ai-game-creator-shell/.taurignore +++ /dev/null @@ -1,4 +0,0 @@ -# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。 -# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发 -# “构建 -> 监听 -> 再构建”的自触发循环。 -resources/plugins/ diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 83dbe4b56..b0cef6b19 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -13,6 +13,10 @@ "skill-pack:check": "node scripts/check-skill-pack.mjs", "skill-pack:sync": "node scripts/check-skill-pack.mjs --write", "skill-pack:test": "node --test scripts/check-skill-pack.test.mjs", + "bundled-resources:check": "node scripts/check-package-layout.mjs", + "bundled-resources:sync": "node scripts/check-package-layout.mjs --write", + "bundled-resources:prepare": "node scripts/prepare-bundled-resources.mjs", + "bundled-resources:test": "node --test scripts/prepare-bundled-resources.test.mjs", "llm-status": "node scripts/run-cli-with-config.mjs --llm-status", "agent-task": "node scripts/run-cli-with-config.mjs --agent-task", "config": "node scripts/game-creator-config-wizard.mjs", @@ -24,7 +28,7 @@ "agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill", "agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs", "agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill", - "typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && node scripts/check-config.mjs" + "typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.3.285", diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 3ceaddaf8..bfb958806 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -11,6 +11,8 @@ import { } from './agc-global-version.mjs'; import { defaultEditorFeatures, + readCargoTarget, + resolveEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; import { @@ -19,6 +21,10 @@ import { resolveReleaseChannel, } from './channel-identity.mjs'; import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs'; +import { + prepareBundledResources, + supportedHostTarget, +} from './prepare-bundled-resources.mjs'; import { stageNodeRuntime } from './stage-node-runtime.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -40,26 +46,7 @@ function defaultTarget() { } function explicitBuildTarget(args) { - let target; - const separator = args.indexOf('--'); - const options = separator < 0 ? args : args.slice(0, separator); - for (let index = 0; index < options.length; index += 1) { - const argument = options[index]; - let value; - if (argument === '--target' || argument === '-t') { - value = options[++index]; - } else if (argument.startsWith('--target=')) { - value = argument.slice('--target='.length); - } else { - continue; - } - if (!value?.trim() || value.startsWith('-')) { - throw new Error('--target 缺少有效目标'); - } - if (target !== undefined) throw new Error('不能重复指定 --target'); - target = value.trim(); - } - return target; + return readCargoTarget(args); } function validateReleaseTarget(target) { @@ -469,10 +456,32 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) { return configPath; } +/// 随包资源必须在打包工具之前生成:构建脚本只做只读校验,不再生成。 +export function stageBundledResources( + target, + { prepare = prepareBundledResources, features } = {}, +) { + const summaries = prepare({ + target, + features: new Set(features ?? defaultEditorFeatures(target)), + profile: 'release', + log: (line) => console.log(`[ai-game-creator-shell] ${line}`), + }); + for (const summary of summaries) { + console.log(`[ai-game-creator-shell] ${summary}`); + } +} + export function runTauriBuild( args = [], context = resolveReleaseContext(args), - { spawn = spawnSync, stageRuntime = stageNodeRuntime } = {}, + { + spawn = spawnSync, + stageRuntime = stageNodeRuntime, + stageBundled = stageBundledResources, + platform = process.platform, + arch = process.arch, + } = {}, ) { if ( explicitBuildTarget(args) && @@ -480,14 +489,44 @@ export function runTauriBuild( ) { throw new Error('构建参数与发布上下文目标不一致'); } - const tauriArguments = buildTauriBuildArguments(args, context.target); - const { channel, target } = context; - if (!args.includes('--no-bundle')) stageRuntime(target); - const configPath = writeChannelConfigFile( - channel, - target, - !args.includes('--no-bundle'), + const tauriArguments = buildTauriBuildArguments( + args, + context.target, + platform, ); + const bundling = !args.includes('--no-bundle'); + // 无显式 target 的 no-bundle 由 Cargo 构建宿主平台,不使用默认发布目标。 + const resourceTarget = + explicitBuildTarget(args) || + (bundling ? context.target : supportedHostTarget(platform, arch)); + if ( + !bundling && + !resourceTarget && + defaultEditorFeatures(platform).length > 0 + ) { + throw new Error( + `当前宿主 ${platform}/${arch} 不在随包资源声明覆盖内;请用 --target 指定受支持的构建目标`, + ); + } + const features = resolveEditorFeatures({ + argv: tauriArguments, + target: resourceTarget ?? platform, + env: {}, + }); + const { channel } = context; + if (bundling) { + stageRuntime(resourceTarget); + } + if (resourceTarget) { + stageBundled(resourceTarget, { features }); + } else { + console.log( + '[ai-game-creator-shell] 当前宿主平台不参与客户端随包资源构建,跳过资源准备', + ); + } + // Linux smoke 不发布 updater,沿用渠道上下文;桌面宿主必须与实际构建目标一致。 + const target = resourceTarget ?? context.target; + const configPath = writeChannelConfigFile(channel, target, bundling); console.log( `[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`, ); 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 8724bcce1..4b6fc20cc 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -38,6 +38,7 @@ import { AGC_PRODUCT_NAME, resolveChannelInstallIdentity, } from './channel-identity.mjs'; +import { supportedHostTarget } from './prepare-bundled-resources.mjs'; const windowsTarget = 'x86_64-pc-windows-msvc'; const universalTarget = 'universal-apple-darwin'; @@ -504,6 +505,8 @@ test('packaged renderer receives the same channel as the updater manifest', () = // 必须 stub:真实 staging 会用宿主平台(如 macOS 的 darwin/arm64)去对默认的 // Windows 目标做一致性校验,在非 Windows 主机上直接失败——本用例只关心渠道注入。 stageRuntime: () => {}, + // 同上:随包资源准备会读取真实上游包,本用例只关心渠道环境变量。 + stageBundled: () => {}, spawn: (_binary, _args, options) => { spawnOptions = options; return { status: 0 }; @@ -594,6 +597,8 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and seenContexts.push(context); runTauriBuild(args, context, { stageRuntime: () => {}, + // 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。 + stageBundled: () => {}, spawn: (_binary, command) => { const configIndex = command.lastIndexOf('--config'); const config = JSON.parse( @@ -843,6 +848,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme context, { stageRuntime: () => {}, + stageBundled: () => {}, spawn: (_binary, command) => { assert.ok( command.includes( @@ -960,6 +966,10 @@ test('release stages Node before Tauri and injects its resource mapping only for assert.equal(target, windowsTarget); events.push('stage'); }, + stageBundled(target) { + assert.equal(target, windowsTarget); + events.push('bundled'); + }, spawn(_binary, args) { events.push('build'); const config = JSON.parse( @@ -975,12 +985,24 @@ test('release stages Node before Tauri and injects its resource mapping only for return { status: 0 }; }, }); - assert.deepEqual(events, ['stage', 'build']); + assert.deepEqual(events, ['stage', 'bundled', 'build']); + events.length = 0; runTauriBuild(['--no-bundle', '--target', windowsTarget], context, { stageRuntime() { - assert.fail('no-bundle must not stage resources'); + assert.fail('no-bundle must not stage node runtime'); + }, + stageBundled(target, options) { + // app 构建本身就需要随包资源,因此 --no-bundle 也要 staging, + // 且必须拿到与 cargo 相同的 feature 集(避免「cargo 开、staging 没开」)。 + assert.equal(target, windowsTarget); + assert.ok( + options?.features?.length > 0, + 'bundled staging 必须收到 feature 集', + ); + events.push('bundled'); }, spawn(_binary, args) { + events.push('build'); const config = JSON.parse( readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'), ); @@ -993,12 +1015,16 @@ test('release stages Node before Tauri and injects its resource mapping only for return { status: 0 }; }, }); + assert.deepEqual(events, ['bundled', 'build']); assert.throws( () => runTauriBuild(['--target', windowsTarget], context, { stageRuntime() { throw new Error('missing runtime'); }, + stageBundled() { + assert.fail('invalid runtime must prevent bundled staging'); + }, spawn() { assert.fail('invalid runtime must prevent build'); }, @@ -1007,6 +1033,297 @@ test('release stages Node before Tauri and injects its resource mapping only for ); }); +const windowsEditorFeatures = [ + 'cocos-editor-execute', + 'unity-editor-execute', + 'godot-editor-execute', +]; + +function uniqueSorted(values) { + return [...new Set(values ?? [])].sort(); +} + +function splitFeatureList(value) { + return String(value ?? '') + .split(',') + .map((name) => name.trim()) + .filter(Boolean); +} + +/// 最终命令行里 Cargo 实际会收到的 feature 集:tauri 自身的 `--features` / `-f` +/// 与 `--` 之后的 runner 参数(tauri 会把它们追加到 cargo 命令)都算数, +/// 随包资源准备必须拿到同一集合。 +function cargoFeaturesFromCommand(command) { + const argv = command.slice(command.indexOf('--') + 1); + const features = []; + for (let index = 0; index < argv.length; index += 1) { + const argument = String(argv[index]); + if (argument.startsWith('--features=')) { + features.push(...splitFeatureList(argument.slice('--features='.length))); + } else if (argument === '--features' || argument === '-f') { + features.push(...splitFeatureList(argv[index + 1])); + index += 1; + } else if (/^-f.+/u.test(argument)) { + features.push(...splitFeatureList(argument.slice(2))); + } + } + return uniqueSorted(features); +} + +/** + * PR520 的 CI 回归:`tauri build --no-bundle` 不带显式 target 时编译的是**宿主**平台, + * 而发布上下文默认目标始终是 Windows。随包资源与 feature 集必须跟着宿主走, + * 否则 Linux 宿主会拿着 Windows 目标去 staging(CI smoke 直接失败), + * 还会出现「cargo 没开 feature、staging 开了」的错配。 + */ +test('no-bundle 在无随包资源声明的宿主(Linux)既不 staging 也不注入 Windows feature', () => { + const context = resolveReleaseContext(['--no-bundle'], {}); + assert.equal(context.target, windowsTarget); + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(['--no-bundle'], context, { + platform: 'linux', + arch: 'x64', + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(target) { + assert.fail(`Linux 宿主没有随包资源声明,不得 staging ${target}`); + }, + spawn(_binary, args) { + command = args; + return { status: 0 }; + }, + }); + }); + assert.ok(command, '宿主不受声明覆盖时仍必须完成本次构建'); + assert.ok(!command.includes('--target'), '宿主构建不得打显式 target'); + assert.deepEqual( + cargoFeaturesFromCommand(command), + [], + 'Linux 宿主不得注入 Windows 编辑器 feature', + ); +}); + +test('no-bundle 在 macOS / Windows 宿主按真实宿主三元组准备随包资源', () => { + const context = resolveReleaseContext(['--no-bundle'], {}); + for (const [platform, arch, hostTarget, expectedFeatures] of [ + ['darwin', 'arm64', 'aarch64-apple-darwin', []], + ['darwin', 'x64', 'x86_64-apple-darwin', []], + ['win32', 'x64', windowsTarget, windowsEditorFeatures], + ]) { + const label = `${platform}/${arch}`; + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(['--no-bundle'], context, { + platform, + arch, + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(target, options) { + staged = { target, features: options?.features }; + }, + spawn(_binary, args) { + command = args; + return { status: 0 }; + }, + }); + }); + assert.equal( + staged?.target, + hostTarget, + `${label} 必须按宿主三元组 staging`, + ); + assert.deepEqual( + uniqueSorted(staged?.features), + uniqueSorted(expectedFeatures), + `${label} staging feature 与宿主不一致`, + ); + assert.deepEqual( + cargoFeaturesFromCommand(command), + uniqueSorted(expectedFeatures), + `${label} Cargo feature 与 staging 不一致`, + ); + assert.ok( + !command.includes('--target'), + `${label} 宿主构建不得打显式 target`, + ); + } +}); + +test('no-bundle 的显式 target 优先于宿主:跨平台 / 跨架构仍按参数准备', () => { + for (const [platform, arch, target, expectedFeatures] of [ + ['linux', 'x64', windowsTarget, windowsEditorFeatures], + ['darwin', 'arm64', 'x86_64-apple-darwin', []], + ]) { + const args = ['--no-bundle', '--target', target]; + const context = resolveReleaseContext(args, {}); + const label = `${platform}/${arch} → ${target}`; + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(args, context, { + platform, + arch, + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(stageTarget, options) { + staged = { target: stageTarget, features: options?.features }; + }, + spawn(_binary, argv) { + command = argv; + return { status: 0 }; + }, + }); + }); + assert.equal(staged?.target, target, `${label} 必须按显式 target staging`); + assert.ok( + command.includes(target), + `${label} 必须把显式 target 传给 Tauri`, + ); + assert.deepEqual( + uniqueSorted(staged?.features), + uniqueSorted(expectedFeatures), + `${label} staging feature 与预期不一致`, + ); + assert.deepEqual( + cargoFeaturesFromCommand(command), + uniqueSorted(expectedFeatures), + `${label} Cargo feature 与 staging 不一致`, + ); + } +}); + +test('打包构建(非 no-bundle)的 staging 目标跟随发布上下文而不是宿主', () => { + const crossArch = 'x86_64-apple-darwin'; + const context = resolveReleaseContext([], { AGC_BUILD_TARGET: crossArch }); + assert.equal(context.target, crossArch); + let runtimeTarget; + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild([], context, { + platform: 'darwin', + arch: 'arm64', + stageRuntime(target) { + runtimeTarget = target; + }, + stageBundled(target, options) { + staged = { target, features: options?.features }; + }, + spawn(_binary, args) { + command = args; + return { status: 0 }; + }, + }); + }); + assert.equal(runtimeTarget, crossArch); + assert.equal( + staged?.target, + crossArch, + '打包构建必须按发布上下文目标 staging', + ); + assert.deepEqual(uniqueSorted(staged?.features), []); + assert.ok( + command.includes(crossArch), + '打包构建必须把发布上下文目标传给 Tauri', + ); + assert.deepEqual(cargoFeaturesFromCommand(command), []); +}); + +test('staging feature 与最终 Cargo feature 一致:-f / --features / -- 之后的 runner 参数', () => { + const context = resolveReleaseContext(['--no-bundle'], {}); + const cases = [ + { + args: ['--no-bundle', '--features', 'custom-editor-feature'], + expected: ['custom-editor-feature'], + }, + { + args: ['--no-bundle', '--features=custom-a,custom-b'], + expected: ['custom-a', 'custom-b'], + }, + { + args: ['--no-bundle', '-f', 'custom-editor-feature'], + expected: ['custom-editor-feature'], + }, + { + args: ['--no-bundle', '-fcustom-editor-feature'], + expected: ['custom-editor-feature'], + }, + // `--` 之后的参数是 runner 参数:tauri 会把它们追加到 cargo 命令行 + // (`tauri build --help`:Command line arguments passed to the runner), + // 因此 Cargo 最终拿到的是「注入的默认值 ∪ runner 参数」,staging 必须一致。 + { + args: ['--no-bundle', '--', '--features=app-runner'], + expected: [...windowsEditorFeatures, 'app-runner'], + }, + // 环境变量泄漏(Jenkins Job 参数)不得让 staging 与 Cargo 分叉。 + { + args: ['--no-bundle'], + env: { AGC_DEV_CARGO_FEATURES: 'cocos-editor-execute' }, + }, + ]; + for (const entry of cases) { + const { args, expected } = entry; + const label = args.join(' '); + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined, ...entry.env }, () => { + runTauriBuild(args, context, { + platform: 'win32', + arch: 'x64', + stageRuntime() { + assert.fail(`${label} 不得准备 Node 运行时`); + }, + stageBundled(target, options) { + staged = { target, features: options?.features }; + }, + spawn(_binary, argv) { + command = argv; + return { status: 0 }; + }, + }); + }); + assert.equal(staged?.target, windowsTarget, label); + const cargoFeatures = cargoFeaturesFromCommand(command); + assert.deepEqual( + uniqueSorted(staged?.features), + cargoFeatures, + `${label}:staging feature 必须等于 Cargo 收到的 feature`, + ); + if (expected) { + assert.deepEqual( + cargoFeatures, + uniqueSorted(expected), + `${label}:Cargo feature 与预期不一致`, + ); + } + } +}); + +test('未注入 platform / arch 时按真实 process 平台取宿主随包目标', () => { + const hostTarget = supportedHostTarget(process.platform, process.arch); + const context = resolveReleaseContext(['--no-bundle'], {}); + const events = []; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(['--no-bundle'], context, { + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(target) { + events.push(target); + }, + spawn() { + return { status: 0 }; + }, + }); + }); + assert.deepEqual(events, hostTarget ? [hostTarget] : []); +}); + test('channel manifest carries version, platform keys and signature', () => { withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => { @@ -1111,6 +1428,8 @@ for (const channel of ['release', 'beta-2']) { ); runTauriBuild([`--target=${target}`], context, { stageRuntime: () => {}, + // 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。 + stageBundled: () => {}, spawn: (_binary, command) => { const config = JSON.parse( readFileSync( diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.mjs index 515432f5b..588912ac1 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.mjs @@ -1,24 +1,135 @@ /** 默认桌面能力;显式 feature 参数优先,不把应用参数当 Cargo 参数。 */ export function withDefaultCargoFeatures(argv, features) { - const separator = argv.indexOf('--'); - const cargoArgs = separator < 0 ? argv : argv.slice(0, separator); - if ( - !features.length || - cargoArgs.some( - (value) => - value === '--features' || - value === '-f' || - value.startsWith('--features=') || - /^-f.+/u.test(value), - ) - ) { + // 只看第一个 `--` 之前的 Tauri 选项:runner 参数区里的 `--features` 会与这里注入的 + // 默认值在 Cargo 侧合并,不能当作「用户已经指定过了」而跳过注入。 + if (!features.length || tauriOptions(argv).some(hasExplicitFeatures)) { return argv; } return [`--features=${features.join(',')}`, ...argv]; } +/// 第一个 `--` 之前是 Tauri 自己的选项,之后是 runner(Cargo)参数。 +function tauriOptions(argv) { + const separator = argv.indexOf('--'); + return separator < 0 ? argv : argv.slice(0, separator); +} + +/** + * 交给 Tauri 与 Cargo 的参数区间:第一个 `--` 之前是 Tauri 选项,之后到第二个 `--` 是 + * runner(Cargo)参数;第二个 `--` 之后由 Tauri 转交应用,不属于构建参数。 + * dev 入口(`buildTauriArguments` 会把启动器的单个 `--` 补成 runner + 应用两段)与发布 + * 入口都用这一份区间解析,两边的目标与 feature 必须来自同一个口径。 + */ +function cargoArguments(argv) { + const separator = argv.indexOf('--'); + if (separator < 0) return argv; + const applicationStart = argv.indexOf('--', separator + 1); + return applicationStart < 0 ? argv : argv.slice(0, applicationStart); +} + +/// 四种拼写(`--features x` / `--features=x` / `-f x` / `-fx`)都算显式指定。 +function hasExplicitFeatures(value) { + return ( + value === '--features' || + value === '-f' || + value.startsWith('--features=') || + /^-f.+/u.test(value) + ); +} + +function parseFeatureNames(raw) { + return String(raw) + .split(/[\s,]+/u) + .map((name) => name.trim()) + .filter(Boolean); +} + export function defaultEditorFeatures(target) { return target === 'win32' || target.includes('windows') ? ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute'] : []; } + +/** + * 显式 Cargo 目标解析:`--target ` / `--target=` / `-t `。 + * 应用参数区(第二个 `--` 之后)不参与解析。未显式指定返回 undefined;取值缺失、 + * 取到另一个选项或重复指定都失败关闭。 + * dev 入口与发布入口共用这一份解析:cargo 的构建目标与随包资源 staging 目标 + * 必须来自同一个显式来源,不能一方读参数、另一方回退宿主默认值。 + */ +export function readCargoTarget(argv = []) { + const options = cargoArguments(argv); + let target; + for (let index = 0; index < options.length; index += 1) { + const argument = String(options[index]); + let value; + if (argument === '--target' || argument === '-t') { + value = options[index + 1]; + index += 1; + } else if (argument.startsWith('--target=')) { + value = argument.slice('--target='.length); + } else if (argument.startsWith('-t') && argument.length > 2) { + value = argument.slice(2).replace(/^=/u, ''); + } else { + continue; + } + if (!value?.trim() || String(value).startsWith('-')) { + throw new Error('--target 缺少有效目标'); + } + if (target !== undefined) throw new Error('不能重复指定 --target'); + target = String(value).trim(); + } + return target; +} + +/** + * 单一声明式解析:本次构建实际生效的编辑器 feature 集。 + * 解析顺序:命令行 `--features`(Cargo 真正收到的参数,不能被环境覆盖) + * > 环境变量 `AGC_DEV_CARGO_FEATURES`(存在即以它为准,空串即关闭默认 feature) + * > 目标平台默认值。 + * 传入的必须是「最终交给 Tauri 的参数」:dev 入口与发布入口的 cargo 参数与随包资源 + * 准备步骤都消费这里的返回值,否则会出现「cargo 开了 feature、staging 没开(或反之)」。 + */ +export function resolveEditorFeatures({ + argv = [], + target, + env = process.env, +} = {}) { + const fromArgv = featuresFromArgv(argv); + if (fromArgv) { + return fromArgv; + } + // 变量存在本身即声明「本次 feature 集由环境决定」:空串是有意的关闭,不是未设置。 + if (env.AGC_DEV_CARGO_FEATURES !== undefined) { + return parseFeatureNames(env.AGC_DEV_CARGO_FEATURES); + } + return defaultEditorFeatures(target); +} + +/// 汇总 cargo 参数区里出现在任何位置的 `--features` / `-f`(Cargo 会把多个 flag 合并); +/// 没有任何 feature flag 时返回 null。显式但为空(如 `--features=`)返回空数组。 +export function featuresFromArgv(argv = []) { + const options = cargoArguments(argv); + const features = []; + let found = false; + for (let index = 0; index < options.length; index += 1) { + const value = String(options[index]); + if (value.startsWith('--features=')) { + found = true; + features.push(...parseFeatureNames(value.slice('--features='.length))); + } else if (value === '--features' || value === '-f') { + found = true; + while ( + index + 1 < options.length && + !String(options[index + 1]).startsWith('-') + ) { + index += 1; + features.push(...parseFeatureNames(options[index])); + } + } else if (value.startsWith('-f') && value.length > 2) { + found = true; + features.push(...parseFeatureNames(value.slice(2).replace(/^=/u, ''))); + } + } + return found ? features : null; +} diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs index 3358524b9..baf462966 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs @@ -2,7 +2,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { buildTauriBuildArguments } from './build-release.mjs'; -import { withDefaultCargoFeatures } from './cargo-features.mjs'; +import { + featuresFromArgv, + withDefaultCargoFeatures, +} from './cargo-features.mjs'; test('Windows release includes the same editor feature as development', () => { assert.deepEqual( @@ -47,3 +50,16 @@ test('explicit Cargo features override defaults in every supported spelling', () ['--features=cocos-editor-execute', '--', '--features=app'], ); }); + +test('features 变参会完整消费空格与逗号分隔的值', () => { + assert.deepEqual( + featuresFromArgv([ + '-f', + 'cocos-editor-execute,unity-editor-execute', + 'godot-editor-execute', + '--target', + 'x86_64-pc-windows-msvc', + ]), + ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute'], + ); +}); diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index a2c349e77..3f76523d2 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -98,10 +98,13 @@ const appInvokeSources = readSourceFiles( new URL('../src/', import.meta.url), new Set(['.ts', '.tsx']), ); -const tauriHandlerSource = fs.readFileSync( +// 生产接线在 desktop,受测的启动诊断实现仍在 main;两者共同构成启动检查范围。 +const tauriHandlerSource = [ new URL('../src-tauri/src/main.rs', import.meta.url), - 'utf8', -); + new URL('../src-tauri/src/desktop.rs', import.meta.url), +] + .map((source) => fs.readFileSync(source, 'utf8')) + .join('\n'); const tauriRustSource = readSourceTree( new URL('../src-tauri/src/', import.meta.url), '.rs', @@ -1358,7 +1361,6 @@ for (const channel of ['release', 'beta-2']) { const expectedBundledDesignAgentResources = { 'design-agent': 'design-agent', - 'resources/claude-agent': 'claude-agent', ...Object.fromEntries( [ 'codex-patch-parser', @@ -1387,6 +1389,7 @@ const expectedBundledWindowsResources = { 'resources/codex/win-x64/NOTICE.md': 'coding-agent/win-x64/NOTICE.md', 'resources/codex/win-x64/manifest.json': 'coding-agent/win-x64/manifest.json', 'resources/plugins': 'plugins', + 'resources/claude-agent': 'claude-agent', }; assert.deepEqual( tauriConfig.bundle?.resources, @@ -1399,6 +1402,13 @@ for (const key of Object.keys(tauriConfig.bundle?.resources ?? {})) { 'AI game creator shell base Tauri config must not require Windows-only Codex resources', ); } + // 基线配置同时服务 Linux(只有 CI 会编译壳 crate):随包资源由准备步骤按目标生成, + // 基线声明它们会让没有 node_modules 的平台在构建期就因资源缺失失败。 + if (String(key).startsWith('resources/')) { + throw new Error( + 'AI game creator shell base Tauri config must not require platform-only bundled resources', + ); + } } assert.deepEqual( windowsTauriConfig.bundle?.resources, @@ -1438,6 +1448,7 @@ assert.deepEqual( ]), ), ['resources/plugins', 'plugins'], + ['resources/claude-agent', 'claude-agent'], ]), 'macOS must bundle the complete native Codex layout and plugin workspace', ); @@ -1659,13 +1670,11 @@ if (!viteConfigSource.includes('allow: [repoRoot]')) { ); } -if ( - !( - tauriConfig.build?.beforeDevCommand?.includes( - 'run ai-game-creator-shell:dev-server', - ) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve') - ) -) { +if (!( + tauriConfig.build?.beforeDevCommand?.includes( + 'run ai-game-creator-shell:dev-server', + ) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve') +)) { throw new Error( 'AI game creator shell beforeDevCommand must start the selected Vite dev server', ); diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs new file mode 100755 index 000000000..5682da895 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -0,0 +1,917 @@ +#!/usr/bin/env node +// 随包资源声明门禁:把 build_support/package-layout.json(唯一人工声明)渲染成 +// build_support/package-layout.generated.rs(Rust 编译期常量),并校验声明结构与不变量。 +// +// 用法: +// node scripts/check-package-layout.mjs 校验生成结果是否与声明一致(不一致 exit 1) +// node scripts/check-package-layout.mjs --write 重新生成 +// +// 设计约束:Rust 侧不解析 JSON(避免运行期解析与生命周期妥协),只使用本脚本产出的常量; +// Node 侧准备步骤直接读同一份 JSON。因此本门禁是“单一声明”的机械保障。 + +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = path.resolve(SCRIPT_DIR, '..'); +const SRC_TAURI = path.join(APP_ROOT, 'src-tauri'); +const DECLARATION_PATH = path.join( + SRC_TAURI, + 'build_support/package-layout.json', +); +const GENERATED_PATH = path.join( + SRC_TAURI, + 'build_support/package-layout.generated.rs', +); + +const EXPECTED_SCHEMA = 'agc-package-layout.v1'; + +class DeclarationError extends Error {} + +function fail(message) { + throw new DeclarationError(message); +} + +function expectObject(value, at) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(`${at} 必须是对象`); + } + return value; +} + +function expectArray(value, at) { + if (!Array.isArray(value)) { + fail(`${at} 必须是数组`); + } + return value; +} + +function expectString(value, at) { + if (typeof value !== 'string' || value.length === 0) { + fail(`${at} 必须是非空字符串`); + } + return value; +} + +function expectBoolean(value, at) { + if (typeof value !== 'boolean') { + fail(`${at} 必须是布尔值`); + } + return value; +} + +function expectStringArray(value, at) { + return expectArray(value, at).map((item, index) => + expectString(item, `${at}[${index}]`), + ); +} + +function optionalStringArray(source, key, at) { + if (source[key] === undefined) { + return []; + } + return expectStringArray(source[key], `${at}.${key}`); +} + +function expectInteger(value, at) { + if (!Number.isInteger(value) || value < 0) { + fail(`${at} 必须是非负整数`); + } + return value; +} + +// 随包子目录来源:`source` 直接来自仓库源码(准备步骤复制), +// `prepared` 需要先由准备步骤运行声明的构建命令产出,再复制进随包目录。 +function expectOrigin(value, at) { + if (value !== 'source' && value !== 'prepared') { + fail(`${at} 必须是 source 或 prepared(实际 ${String(value)})`); + } + return value; +} + +// JSON 字符串字面量与 Rust 字符串字面量几乎一致,唯一差异是控制字符的 \uXXXX 与 \u{XX}。 +function rustString(value) { + return JSON.stringify(value).replace(/\\u([0-9a-fA-F]{4})/g, '\\u{$1}'); +} + +function rustStrings(values) { + return `&[${values.map(rustString).join(', ')}]`; +} + +function indent(level) { + return ' '.repeat(level); +} + +function renderStruct(name, fields, level = 0) { + const body = fields + .map(([key, rendered]) => `${indent(level + 1)}${key}: ${rendered},`) + .join('\n'); + return `${name} {\n${body}\n${indent(level)}}`; +} + +function parsePackageMetadata(value) { + const metadata = expectObject(value, 'codex.packageMetadata'); + return { + layoutVersion: expectInteger( + metadata.layoutVersion, + 'codex.packageMetadata.layoutVersion', + ), + resourcesDir: expectString( + metadata.resourcesDir, + 'codex.packageMetadata.resourcesDir', + ), + pathDir: expectString(metadata.pathDir, 'codex.packageMetadata.pathDir'), + }; +} + +function parseDeclaration(raw) { + const root = expectObject(JSON.parse(raw), 'root'); + if (root.schema !== EXPECTED_SCHEMA) { + fail( + `不支持的声明 schema:${String(root.schema)}(期望 ${EXPECTED_SCHEMA})`, + ); + } + const layoutVersion = expectInteger(root.layoutVersion, 'layoutVersion'); + + const codex = expectObject(root.codex, 'codex'); + const targets = expectArray(codex.targets, 'codex.targets').map( + (entry, index) => { + const at = `codex.targets[${index}]`; + const parsed = expectObject(entry, at); + const files = expectStringArray(parsed.files, `${at}.files`); + if (files.length === 0) { + fail(`${at}.files 不能为空`); + } + const executable = expectString(parsed.executable, `${at}.executable`); + if (!files.includes(executable)) { + fail(`${at}.executable 必须属于组件白名单:${executable}`); + } + return { + target: expectString(parsed.target, `${at}.target`), + platform: expectString(parsed.platform, `${at}.platform`), + directory: expectString(parsed.directory, `${at}.directory`), + executable, + files, + }; + }, + ); + const seenTargets = new Set(); + for (const entry of targets) { + if (seenTargets.has(entry.target)) { + fail(`codex.targets 重复声明目标 ${entry.target}`); + } + seenTargets.add(entry.target); + } + + const noticeSources = expectArray( + codex.noticeSources, + 'codex.noticeSources', + ).map((entry, index) => { + const at = `codex.noticeSources[${index}]`; + const parsed = expectObject(entry, at); + return { + targets: expectStringArray(parsed.targets, `${at}.targets`), + source: expectString(parsed.source, `${at}.source`), + preserve: expectBoolean(parsed.preserve, `${at}.preserve`), + }; + }); + for (const entry of targets) { + const covered = noticeSources.filter((notice) => + notice.targets.includes(entry.target), + ); + if (covered.length !== 1) { + fail( + `目标 ${entry.target} 必须且只能有一条第三方声明来源(实际 ${covered.length} 条)`, + ); + } + } + + const universalGroups = expectArray( + codex.universalGroups, + 'codex.universalGroups', + ).map((entry, index) => { + const at = `codex.universalGroups[${index}]`; + const parsed = expectObject(entry, at); + const groupTargets = expectStringArray(parsed.targets, `${at}.targets`); + for (const target of groupTargets) { + if (!seenTargets.has(target)) { + fail(`${at}.targets 含未声明目标 ${target}`); + } + } + return { + name: expectString(parsed.name, `${at}.name`), + directory: expectString(parsed.directory, `${at}.directory`), + targets: groupTargets, + }; + }); + + const plugins = expectObject(root.plugins, 'plugins'); + const subdirectories = expectArray( + plugins.subdirectories, + 'plugins.subdirectories', + ).map((entry, index) => { + const at = `plugins.subdirectories[${index}]`; + const parsed = expectObject(entry, at); + return { + path: expectString(parsed.path, `${at}.path`), + origin: expectOrigin(parsed.origin, `${at}.origin`), + plugin: + parsed.plugin === undefined + ? '' + : expectString(parsed.plugin, `${at}.plugin`), + targetContains: optionalStringArray(parsed, 'targetContains', at), + targets: optionalStringArray(parsed, 'targets', at), + features: optionalStringArray(parsed, 'features', at), + }; + }); + const libraryStaging = expectArray( + plugins.libraryStaging, + 'plugins.libraryStaging', + ).map((entry, index) => { + const at = `plugins.libraryStaging[${index}]`; + const parsed = expectObject(entry, at); + const files = expectStringArray(parsed.files, `${at}.files`); + if (files.length === 0) { + fail(`${at}.files 不能为空`); + } + return { + plugin: expectString(parsed.plugin, `${at}.plugin`), + sourceSubdirectory: expectString( + parsed.sourceSubdirectory, + `${at}.sourceSubdirectory`, + ), + prepare: expectString(parsed.prepare, `${at}.prepare`), + targets: expectStringArray(parsed.targets, `${at}.targets`), + features: expectStringArray(parsed.features, `${at}.features`), + layout: expectString(parsed.layout, `${at}.layout`), + files, + }; + }); + + const claudeAgent = expectObject(root.claudeAgent, 'claudeAgent'); + const claudeAgentTargets = expectArray( + claudeAgent.targets, + 'claudeAgent.targets', + ).map((entry, index) => { + const at = `claudeAgent.targets[${index}]`; + const parsed = expectObject(entry, at); + return { + target: expectString(parsed.target, `${at}.target`), + runtimePackage: expectString( + parsed.runtimePackage, + `${at}.runtimePackage`, + ), + runtimeFileName: expectString( + parsed.runtimeFileName, + `${at}.runtimeFileName`, + ), + }; + }); + const seenClaudeAgentTargets = new Set(); + for (const entry of claudeAgentTargets) { + if (seenClaudeAgentTargets.has(entry.target)) { + fail(`claudeAgent.targets 重复声明目标 ${entry.target}`); + } + seenClaudeAgentTargets.add(entry.target); + } + const sdkPackageName = expectString( + claudeAgent.sdkPackageName, + 'claudeAgent.sdkPackageName', + ); + if (!/^@[^/]+\/[^/]+$/u.test(sdkPackageName)) { + fail(`claudeAgent.sdkPackageName 必须是带 scope 的包名:${sdkPackageName}`); + } + for (const entry of claudeAgentTargets) { + // 原生运行时包与 SDK 同处一个 scope 目录下,包名必须是裸名。 + if (entry.runtimePackage.includes('/')) { + fail( + `claudeAgent.targets 的 runtimePackage 必须是裸包名:${entry.runtimePackage}`, + ); + } + } + + return { + layoutVersion, + codex: { + version: expectString(codex.version, 'codex.version'), + cliVersionPrefix: expectString( + codex.cliVersionPrefix, + 'codex.cliVersionPrefix', + ), + manifestSchema: expectString( + codex.manifestSchema, + 'codex.manifestSchema', + ), + packageMetadata: parsePackageMetadata(codex.packageMetadata), + resourceDirectory: expectString( + codex.resourceDirectory, + 'codex.resourceDirectory', + ), + manifestFileName: expectString( + codex.manifestFileName, + 'codex.manifestFileName', + ), + packageMetadataFileName: expectString( + codex.packageMetadataFileName, + 'codex.packageMetadataFileName', + ), + noticeFileName: expectString( + codex.noticeFileName, + 'codex.noticeFileName', + ), + sourceRoots: expectStringArray(codex.sourceRoots, 'codex.sourceRoots'), + sourceRelativePaths: expectStringArray( + codex.sourceRelativePaths, + 'codex.sourceRelativePaths', + ), + noticeSources, + universalGroups, + targets, + }, + claudeAgent: { + version: expectString(claudeAgent.version, 'claudeAgent.version'), + resourceDirectory: assertRelativePath( + expectString( + claudeAgent.resourceDirectory, + 'claudeAgent.resourceDirectory', + ), + 'claudeAgent.resourceDirectory', + ), + entryRelativePath: assertRelativePath( + expectString( + claudeAgent.entryRelativePath, + 'claudeAgent.entryRelativePath', + ), + 'claudeAgent.entryRelativePath', + ), + entryFileName: expectString( + claudeAgent.entryFileName, + 'claudeAgent.entryFileName', + ), + nodeModulesDirectory: expectString( + claudeAgent.nodeModulesDirectory, + 'claudeAgent.nodeModulesDirectory', + ), + sdkPackageName, + sdkEntryFileName: expectString( + claudeAgent.sdkEntryFileName, + 'claudeAgent.sdkEntryFileName', + ), + sdkPackageMetadataFileName: expectString( + claudeAgent.sdkPackageMetadataFileName, + 'claudeAgent.sdkPackageMetadataFileName', + ), + sourceRoots: expectStringArray( + claudeAgent.sourceRoots, + 'claudeAgent.sourceRoots', + ), + skipDirectoryNames: expectStringArray( + claudeAgent.skipDirectoryNames, + 'claudeAgent.skipDirectoryNames', + ), + skipDirectoryNamePrefixes: expectStringArray( + claudeAgent.skipDirectoryNamePrefixes, + 'claudeAgent.skipDirectoryNamePrefixes', + ), + skipFileNamePrefixes: expectStringArray( + claudeAgent.skipFileNamePrefixes, + 'claudeAgent.skipFileNamePrefixes', + ), + skipFileNameFragments: expectStringArray( + claudeAgent.skipFileNameFragments, + 'claudeAgent.skipFileNameFragments', + ), + targets: claudeAgentTargets, + }, + plugins: { + sourceDirectory: expectString( + plugins.sourceDirectory, + 'plugins.sourceDirectory', + ), + destinationDirectory: expectString( + plugins.destinationDirectory, + 'plugins.destinationDirectory', + ), + manifestFileName: expectString( + plugins.manifestFileName, + 'plugins.manifestFileName', + ), + targetContainsAny: expectStringArray( + plugins.targetContainsAny, + 'plugins.targetContainsAny', + ), + subdirectories, + libraryStaging, + skipDirectoryNames: expectStringArray( + plugins.skipDirectoryNames, + 'plugins.skipDirectoryNames', + ), + skipDirectoryNamePrefixes: expectStringArray( + plugins.skipDirectoryNamePrefixes, + 'plugins.skipDirectoryNamePrefixes', + ), + skipFileNamePrefixes: expectStringArray( + plugins.skipFileNamePrefixes, + 'plugins.skipFileNamePrefixes', + ), + skipFileNameFragments: expectStringArray( + plugins.skipFileNameFragments, + 'plugins.skipFileNameFragments', + ), + }, + }; +} + +function renderCodexTarget(entry) { + return renderStruct( + 'CodexTarget', + [ + ['target', rustString(entry.target)], + ['platform', rustString(entry.platform)], + ['directory', rustString(entry.directory)], + ['executable', rustString(entry.executable)], + ['files', rustStrings(entry.files)], + ], + 1, + ); +} + +function renderNoticeSource(entry) { + return renderStruct( + 'NoticeSource', + [ + ['targets', rustStrings(entry.targets)], + ['source', rustString(entry.source)], + ['preserve', entry.preserve ? 'true' : 'false'], + ], + 1, + ); +} + +function renderUniversalGroup(entry) { + return renderStruct( + 'UniversalGroup', + [ + ['name', rustString(entry.name)], + ['directory', rustString(entry.directory)], + ['targets', rustStrings(entry.targets)], + ], + 1, + ); +} + +function renderSubdirectory(entry) { + return renderStruct( + 'Subdirectory', + [ + ['path', rustString(entry.path)], + ['plugin', rustString(entry.plugin ?? '')], + ['origin', rustString(entry.origin)], + ['target_contains', rustStrings(entry.targetContains)], + ['targets', rustStrings(entry.targets)], + ['features', rustStrings(entry.features)], + ], + 1, + ); +} + +function renderLibraryStaging(entry) { + return renderStruct( + 'LibraryStaging', + [ + ['plugin', rustString(entry.plugin)], + ['source_subdirectory', rustString(entry.sourceSubdirectory)], + ['targets', rustStrings(entry.targets)], + ['features', rustStrings(entry.features)], + ['layout', rustString(entry.layout)], + ['files', rustStrings(entry.files)], + ], + 1, + ); +} + +function renderClaudeAgentTarget(entry) { + return renderStruct( + 'ClaudeAgentTarget', + [ + ['target', rustString(entry.target)], + ['runtime_package', rustString(entry.runtimePackage)], + ['runtime_file_name', rustString(entry.runtimeFileName)], + ], + 1, + ); +} + +function renderGenerated(declaration) { + const codex = declaration.codex; + const godotStaging = declaration.plugins.libraryStaging.find( + (entry) => entry.layout === 'godot-bundle', + ); + if (!godotStaging || godotStaging.files.length === 0) { + fail('声明缺少 godot-bundle 随包文件清单,拒绝生成空清单'); + } + const godotFiles = godotStaging.files; + const plugins = declaration.plugins; + const codexStruct = renderStruct('Codex', [ + [ + 'package_metadata', + renderStruct('PackageMetadata', [ + ['layout_version', String(codex.packageMetadata.layoutVersion)], + ['resources_dir', rustString(codex.packageMetadata.resourcesDir)], + ['path_dir', rustString(codex.packageMetadata.pathDir)], + ]), + ], + ['resource_directory', rustString(codex.resourceDirectory)], + ['manifest_file_name', rustString(codex.manifestFileName)], + ['package_metadata_file_name', rustString(codex.packageMetadataFileName)], + ['notice_file_name', rustString(codex.noticeFileName)], + ['source_roots', rustStrings(codex.sourceRoots)], + ['source_relative_paths', rustStrings(codex.sourceRelativePaths)], + [ + 'notice_sources', + `&[\n${codex.noticeSources.map(renderNoticeSource).join(',\n')}\n]`, + ], + [ + 'universal_groups', + `&[\n${codex.universalGroups.map(renderUniversalGroup).join(',\n')}\n]`, + ], + ['targets', `&[\n${codex.targets.map(renderCodexTarget).join(',\n')}\n]`], + ]); + const claudeAgent = declaration.claudeAgent; + const claudeAgentStruct = renderStruct('ClaudeAgent', [ + ['version', rustString(claudeAgent.version)], + ['resource_directory', rustString(claudeAgent.resourceDirectory)], + ['entry_relative_path', rustString(claudeAgent.entryRelativePath)], + ['entry_file_name', rustString(claudeAgent.entryFileName)], + ['node_modules_directory', rustString(claudeAgent.nodeModulesDirectory)], + ['sdk_package_name', rustString(claudeAgent.sdkPackageName)], + ['sdk_entry_file_name', rustString(claudeAgent.sdkEntryFileName)], + [ + 'sdk_package_metadata_file_name', + rustString(claudeAgent.sdkPackageMetadataFileName), + ], + [ + 'targets', + `&[\n${claudeAgent.targets.map(renderClaudeAgentTarget).join(',\n')}\n]`, + ], + ]); + const pluginsStruct = renderStruct('Plugins', [ + ['source_directory', rustString(plugins.sourceDirectory)], + ['destination_directory', rustString(plugins.destinationDirectory)], + ['manifest_file_name', rustString(plugins.manifestFileName)], + ['target_contains_any', rustStrings(plugins.targetContainsAny)], + [ + 'subdirectories', + `&[\n${plugins.subdirectories.map(renderSubdirectory).join(',\n')}\n]`, + ], + [ + 'library_staging', + `&[\n${plugins.libraryStaging.map(renderLibraryStaging).join(',\n')}\n]`, + ], + ['skip_directory_names', rustStrings(plugins.skipDirectoryNames)], + [ + 'skip_directory_name_prefixes', + rustStrings(plugins.skipDirectoryNamePrefixes), + ], + ['skip_file_name_prefixes', rustStrings(plugins.skipFileNamePrefixes)], + ['skip_file_name_fragments', rustStrings(plugins.skipFileNameFragments)], + ]); + + return `// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs +// 来源:build_support/package-layout.json。不要手工编辑本文件。 +// 修改随包资源布局请编辑声明文件,然后运行 +// npm run agc:bundled-resources:sync(在仓库根目录) +// 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。 + +pub const DECLARATION_SCHEMA: &str = ${rustString(EXPECTED_SCHEMA)}; +pub const LAYOUT_VERSION: u64 = ${declaration.layoutVersion}; + +pub const CODEX_VERSION: &str = ${rustString(declaration.codex.version)}; +pub const CODEX_CLI_VERSION: &str = ${rustString(declaration.codex.cliVersionPrefix + declaration.codex.version)}; +pub const CODEX_MANIFEST_SCHEMA: &str = ${rustString(declaration.codex.manifestSchema)}; + +pub const CLAUDE_AGENT_SDK_VERSION: &str = ${rustString(declaration.claudeAgent.version)}; + +pub const GODOT_BUNDLE_FILES: &[&str] = ${rustStrings(godotFiles)}; + +pub const CODEX: Codex = ${codexStruct}; + +pub const CLAUDE_AGENT: ClaudeAgent = ${claudeAgentStruct}; + +pub const PLUGINS: Plugins = ${pluginsStruct}; +`; +} + +function appLockedCodexVersion() { + const appPackage = expectObject( + JSON.parse(readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')), + 'apps/ai-game-creator-shell/package.json', + ); + const declared = + appPackage.dependencies?.['@openai/codex'] ?? + appPackage.devDependencies?.['@openai/codex']; + if (typeof declared !== 'string' || declared.length === 0) { + fail( + '应用 package.json 未声明 @openai/codex,无法校验声明的 codex.version', + ); + } + return declared.replace(/^[\^~]/, ''); +} + +/// Claude Agent SDK 有两个锁点:客户端依赖与 sidecar 自己的依赖。两处都必须与声明一致, +/// 否则随包的 SDK 与运行它的 sidecar 会悄悄分叉。 +function lockedClaudeAgentVersions() { + const manifests = [ + [ + 'apps/ai-game-creator-shell/package.json', + path.join(APP_ROOT, 'package.json'), + ], + [ + 'apps/ai-game-creator-shell/agent-sidecar/package.json', + path.join(APP_ROOT, 'agent-sidecar', 'package.json'), + ], + ]; + return manifests.map(([label, file]) => { + const manifest = expectObject( + JSON.parse(readFileSync(file, 'utf8')), + label, + ); + const declared = manifest.dependencies?.['@anthropic-ai/claude-agent-sdk']; + if (typeof declared !== 'string' || declared.length === 0) { + fail( + `${label} 未声明 @anthropic-ai/claude-agent-sdk,无法校验声明的 claudeAgent.version`, + ); + } + return declared.replace(/^[\^~]/, ''); + }); +} + +const REPO_ROOT = path.resolve(APP_ROOT, '..', '..'); +const PLUGINS_ROOT = path.join(REPO_ROOT, 'plugins'); +const SHELL_MANIFEST_PATH = path.join(SRC_TAURI, 'Cargo.toml'); + +/// 声明里的路径必须是仓库内相对路径:绝对路径或含 `..` 会写到工作区之外。 +function assertRelativePath(value, at) { + if ( + path.isAbsolute(value) || + value.startsWith('/') || + value.split('/').includes('..') + ) { + fail(`${at} 必须是仓库内相对路径(不允许绝对路径或 ..):${value}`); + } + return value; +} + +/// 声明引用的插件必须是真实存在的插件目录(拼错插件的后果是静默不交付)。 +function assertKnownPlugin(name, at) { + const directory = path.join(PLUGINS_ROOT, name); + if (!existsSync(path.join(directory, 'plugin.json'))) { + fail(`${at} 指向的插件不存在或缺少 plugin.json:${name}`); + } + return name; +} + +/// shell crate 的 feature 表(拼错 feature 的后果同样是静默不交付)。 +function shellCrateFeatures() { + const manifest = readFileSync(SHELL_MANIFEST_PATH, 'utf8'); + const section = /\[features\]([\s\S]*?)(?:\n\[|$)/u.exec(manifest); + if (!section) { + fail('无法从 src-tauri/Cargo.toml 读取 [features] 段'); + } + return new Set( + section[1] + .split('\n') + .map((line) => /^([A-Za-z0-9_-]+)\s*=/u.exec(line.trim())?.[1]) + .filter(Boolean), + ); +} + +/// 新 section(prepareSteps / nativePayloads / prepared 来源)不参与 Rust 生成物, +/// 必须在门禁里单独把关,避免「声明了却没人用」或引用到不存在的准备步骤。 +function validateExtendedDeclarations() { + const root = expectObject( + JSON.parse(readFileSync(DECLARATION_PATH, 'utf8')), + 'root', + ); + const plugins = expectObject(root.plugins, 'plugins'); + const steps = expectArray(plugins.prepareSteps ?? [], 'plugins.prepareSteps'); + const names = new Set(); + for (const [index, raw] of steps.entries()) { + const at = `plugins.prepareSteps[${index}]`; + const step = expectObject(raw, at); + const name = expectString(step.name, `${at}.name`); + if (names.has(name)) { + fail(`${at}.name 重复:${name}`); + } + names.add(name); + const kind = expectString(step.kind, `${at}.kind`); + if (kind !== 'powershell' && kind !== 'cargo') { + fail(`${at}.kind 只能是 powershell 或 cargo`); + } + if (kind === 'powershell') { + expectString(step.workingDirectory, `${at}.workingDirectory`); + expectString(step.scriptFileName, `${at}.scriptFileName`); + } else { + expectString(step.packageDirectory, `${at}.packageDirectory`); + } + expectStringArray(step.requiredOutputs ?? [], `${at}.requiredOutputs`); + } + const payloads = expectArray( + plugins.nativePayloads ?? [], + 'plugins.nativePayloads', + ); + for (const [index, raw] of payloads.entries()) { + const at = `plugins.nativePayloads[${index}]`; + const payload = expectObject(raw, at); + expectString(payload.plugin, `${at}.plugin`); + expectString(payload.prepare, `${at}.prepare`); + expectString(payload.sourceFileName, `${at}.sourceFileName`); + expectString(payload.destinationFileName, `${at}.destinationFileName`); + expectString( + payload.destinationSubdirectory, + `${at}.destinationSubdirectory`, + ); + if (!names.has(payload.prepare)) { + fail(`${at}.prepare 引用了未声明的准备步骤:${payload.prepare}`); + } + } + const preparedByPlugin = new Map(); + for (const entry of expectArray( + plugins.subdirectories ?? [], + 'plugins.subdirectories', + )) { + const subdirectory = expectObject(entry, 'subdirectory'); + if (subdirectory.origin !== 'prepared') { + continue; + } + const owner = expectString( + subdirectory.plugin ?? '', + 'subdirectory.plugin', + ); + if (!owner) { + fail(`origin=prepared 的子目录必须声明 plugin:${subdirectory.path}`); + } + preparedByPlugin.set(owner, [ + ...(preparedByPlugin.get(owner) ?? []), + subdirectory.path, + ]); + } + for (const payload of payloads) { + const candidates = preparedByPlugin.get(payload.plugin) ?? []; + if (!candidates.includes(payload.destinationSubdirectory)) { + fail( + `nativePayloads 的 destinationSubdirectory(${payload.destinationSubdirectory})必须是该插件 origin=prepared 的子目录之一(现有:${candidates.join('、') || '无'})`, + ); + } + } + + const knownFeatures = shellCrateFeatures(); + const checkFeatures = (values, at) => { + for (const name of expectStringArray(values ?? [], at)) { + if (!knownFeatures.has(name)) { + fail(`${at} 引用了 Cargo.toml 里不存在的 feature:${name}`); + } + } + }; + for (const [index, raw] of expectArray( + plugins.subdirectories ?? [], + 'plugins.subdirectories', + ).entries()) { + const at = `plugins.subdirectories[${index}]`; + const subdirectory = expectObject(raw, at); + assertRelativePath( + expectString(subdirectory.path, `${at}.path`), + `${at}.path`, + ); + if (subdirectory.plugin) { + assertKnownPlugin(subdirectory.plugin, `${at}.plugin`); + } + checkFeatures(subdirectory.features, `${at}.features`); + } + for (const [index, raw] of expectArray( + plugins.libraryStaging ?? [], + 'plugins.libraryStaging', + ).entries()) { + const at = `plugins.libraryStaging[${index}]`; + const staging = expectObject(raw, at); + assertKnownPlugin( + expectString(staging.plugin, `${at}.plugin`), + `${at}.plugin`, + ); + assertRelativePath( + expectString(staging.sourceSubdirectory, `${at}.sourceSubdirectory`), + `${at}.sourceSubdirectory`, + ); + for (const relative of expectStringArray( + staging.files ?? [], + `${at}.files`, + )) { + assertRelativePath(relative, `${at}.files`); + } + checkFeatures(staging.features, `${at}.features`); + } + for (const payload of payloads) { + assertRelativePath( + payload.destinationSubdirectory, + `nativePayloads.${payload.plugin}.destinationSubdirectory`, + ); + checkFeatures( + payload.features, + `nativePayloads.${payload.plugin}.features`, + ); + } + for (const [index, raw] of steps.entries()) { + const at = `plugins.prepareSteps[${index}]`; + const step = expectObject(raw, at); + for (const key of ['workingDirectory', 'packageDirectory']) { + if (step[key]) { + assertRelativePath( + expectString(step[key], `${at}.${key}`), + `${at}.${key}`, + ); + } + } + for (const relative of expectStringArray( + step.requiredOutputs ?? [], + `${at}.requiredOutputs`, + )) { + assertRelativePath(relative, `${at}.requiredOutputs`); + } + } + + const referenced = [ + ...expectArray(plugins.subdirectories ?? [], 'plugins.subdirectories') + .filter( + (entry) => expectObject(entry, 'subdirectory').origin === 'prepared', + ) + .map((entry) => [entry.path, entry.prepare]), + ...expectArray(plugins.libraryStaging ?? [], 'plugins.libraryStaging').map( + (entry) => [entry.sourceSubdirectory, entry.prepare], + ), + ...payloads.map((entry) => [ + `${entry.plugin}/${entry.destinationSubdirectory}`, + entry.prepare, + ]), + ]; + for (const [label, prepare] of referenced) { + if (!prepare) { + fail(`prepared 来源的条目缺少 prepare 声明:${label}`); + } + if (!names.has(prepare)) { + fail(`${label} 引用了未声明的准备步骤:${prepare}`); + } + } +} + +function main() { + const declaration = parseDeclaration(readFileSync(DECLARATION_PATH, 'utf8')); + validateExtendedDeclarations(); + const lockedVersion = appLockedCodexVersion(); + if (lockedVersion !== declaration.codex.version) { + fail( + `声明 codex.version(${declaration.codex.version})与应用锁定的 @openai/codex(${lockedVersion})不一致;请同步更新 build_support/package-layout.json`, + ); + } + for (const lockedClaudeVersion of lockedClaudeAgentVersions()) { + if (lockedClaudeVersion !== declaration.claudeAgent.version) { + fail( + `声明 claudeAgent.version(${declaration.claudeAgent.version})与锁定的 @anthropic-ai/claude-agent-sdk(${lockedClaudeVersion})不一致;请同步更新 build_support/package-layout.json`, + ); + } + } + const rendered = renderGenerated(declaration); + const write = process.argv.includes('--write'); + if (write) { + writeFileSync(GENERATED_PATH, rendered); + console.log( + `随包资源声明已生成:${path.relative(process.cwd(), GENERATED_PATH)}(layoutVersion ${declaration.layoutVersion})`, + ); + return; + } + const current = readFileSync(GENERATED_PATH, 'utf8'); + if (current !== rendered) { + console.error( + [ + `随包资源声明与 Rust 常量不一致:`, + ` 声明:${path.relative(process.cwd(), DECLARATION_PATH)}`, + ` 生成:${path.relative(process.cwd(), GENERATED_PATH)}`, + '请运行:npm run agc:bundled-resources:sync', + ].join('\n'), + ); + process.exit(1); + } + console.log( + `随包资源声明一致(layoutVersion ${declaration.layoutVersion},codex ${declaration.codex.version},目标 ${declaration.codex.targets.length},插件子目录 ${declaration.plugins.subdirectories.length})`, + ); +} + +try { + main(); +} catch (error) { + if (error instanceof DeclarationError) { + console.error(`随包资源声明无效:${error.message}`); + process.exit(1); + } + throw error; +} diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs new file mode 100755 index 000000000..1b25f2585 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -0,0 +1,1773 @@ +#!/usr/bin/env node +// AGC 随包资源准备步骤:在 `tauri dev` / `tauri build` 之前把随包资源一次性 staging 到位。 +// +// 合同见 docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md §4.3: +// 只替换本工具产物、写入临时目录后原子替换、命中缓存不重写任何文件、失败即关闭并给出可执行提示。 +// 布局与组件白名单来自唯一声明 src-tauri/build_support/package-layout.json(Node 与 Rust 共用)。 +// +// 本里程碑(M1)覆盖两条纯复制路径:随包 Codex CLI 与插件工作区。 +// 编辑器分支产物(Unity/Godot/Cocos)仍由构建脚本生成,归位在 M3。 +// +// 用法(在 apps/ai-game-creator-shell 下): +// node scripts/prepare-bundled-resources.mjs [--target ] [--dry-run] + +import { spawnSync } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { + chmodSync, + closeSync, + copyFileSync, + existsSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + readSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { defaultEditorFeatures } from './cargo-features.mjs'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = path.resolve(SCRIPT_DIR, '..'); +const REPO_ROOT = path.resolve(APP_ROOT, '..', '..'); +/** 应用 src-tauri 目录(随包资源的落点)。 */ +export const SRC_TAURI_DIR = path.join(APP_ROOT, 'src-tauri'); +/** 唯一的随包资源声明文件。 */ +export const DECLARATION_PATH = path.join( + SRC_TAURI_DIR, + 'build_support/package-layout.json', +); +/** 缓存记录(放在 gitignored 的 target 下,丢失只多一次哈希)。 */ +export const RECORD_PATH = path.join( + SRC_TAURI_DIR, + 'target/agc-resource-staging.json', +); + +const HOST_TRIPLES = new Map([ + ['darwin:arm64', 'aarch64-apple-darwin'], + ['darwin:x64', 'x86_64-apple-darwin'], + ['win32:x64', 'x86_64-pc-windows-msvc'], +]); + +class PrepareError extends Error {} + +function fail(message) { + throw new PrepareError(message); +} + +/// 声明覆盖的宿主目标;不受声明覆盖的平台返回 null(入口据此跳过资源准备)。 +export function supportedHostTarget( + platform = process.platform, + arch = process.arch, +) { + return HOST_TRIPLES.get(`${platform}:${arch}`) ?? null; +} + +export function resolveHostTarget( + platform = process.platform, + arch = process.arch, +) { + const triple = supportedHostTarget(platform, arch); + if (!triple) { + fail( + `不支持的目标平台:${platform}/${arch}(随包资源声明只覆盖 ${[...HOST_TRIPLES.values()].join('、')})`, + ); + } + return triple; +} + +export function readDeclaration(declarationPath = DECLARATION_PATH) { + const declaration = JSON.parse(readFileSync(declarationPath, 'utf8')); + if (declaration.schema !== 'agc-package-layout.v1') { + fail(`不支持的随包资源声明 schema:${String(declaration.schema)}`); + } + return declaration; +} + +function codexLayout(declaration, target) { + const layout = declaration.codex.targets.find( + (entry) => entry.target === target, + ); + if (!layout) { + fail(`随包资源声明不含目标 ${target} 的 Codex 布局`); + } + return layout; +} + +/// staging 的替换单位:属于整目录分组时替换整个分组目录(macOS 双架构共用),否则替换自身目录。 +export function stagingUnit(declaration, target) { + const layout = codexLayout(declaration, target); + const group = declaration.codex.universalGroups.find((entry) => + entry.targets.includes(target), + ); + const directory = group ? group.directory : layout.directory; + return { + directory, + targets: (group ? group.targets : [target]).map((member) => ({ + target: member, + layout: codexLayout(declaration, member), + })), + }; +} + +function sha256File(file) { + const hash = createHash('sha256'); + const buffer = Buffer.allocUnsafe(64 * 1024); + const descriptor = openSync(file, 'r'); + try { + for (;;) { + const read = readSync(descriptor, buffer, 0, buffer.length, null); + if (read === 0) { + break; + } + hash.update(buffer.subarray(0, read)); + } + } finally { + closeSync(descriptor); + } + return hash.digest('hex'); +} + +function sha256Text(text) { + return createHash('sha256').update(text).digest('hex'); +} + +function copyFilePreservingMode(source, destination) { + const info = statSync(source); + if (!info.isFile()) { + fail(`随包资源来源不是普通文件:${source}`); + } + mkdirSync(path.dirname(destination), { recursive: true }); + copyFileSync(source, destination); + chmodSync(destination, info.mode & 0o777); +} + +export function readRecord(recordPath = RECORD_PATH) { + if (!existsSync(recordPath)) { + return {}; + } + try { + return JSON.parse(readFileSync(recordPath, 'utf8')); + } catch { + return {}; + } +} + +function writeRecord(record, recordPath) { + mkdirSync(path.dirname(recordPath), { recursive: true }); + writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`); +} + +/// 上游平台包目录:按声明顺序取第一个「声明白名单文件齐全」的候选。 +export function findCodexSource(declaration, target, roots) { + const layout = codexLayout(declaration, target); + const candidates = []; + for (const rootName of declaration.codex.sourceRoots) { + const root = roots[rootName]; + if (!root) { + fail(`未知的声明 sourceRoots 取值:${rootName}`); + } + for (const relative of declaration.codex.sourceRelativePaths) { + candidates.push( + path.join( + root, + relative + .replaceAll('', layout.platform) + .replaceAll('', target), + ), + ); + } + } + const source = candidates.find((candidate) => + layout.files.every((entry) => existsSync(path.join(candidate, entry))), + ); + if (!source) { + fail( + `内置 Codex CLI 上游包缺失;请先在仓库根目录执行 npm ci(已检查:${candidates.join(';')})`, + ); + } + return source; +} + +/// 上游原生包元数据必须与声明一致:版本、目标、入口、资源目录与 path 目录。 +export function validateUpstreamMetadata(declaration, target, source) { + const metadataFile = path.join( + source, + declaration.codex.packageMetadataFileName, + ); + const metadata = JSON.parse(readFileSync(metadataFile, 'utf8')); + const layout = codexLayout(declaration, target); + const expected = declaration.codex.packageMetadata; + const mismatch = + metadata.layoutVersion !== expected.layoutVersion || + metadata.version !== declaration.codex.version || + metadata.target !== target || + metadata.entrypoint !== layout.executable || + metadata.resourcesDir !== expected.resourcesDir || + metadata.pathDir !== expected.pathDir; + if (mismatch) { + fail( + `内置 Codex CLI 上游包元数据与声明不一致(${metadataFile}):期望 layoutVersion=${expected.layoutVersion} version=${declaration.codex.version} target=${target} entrypoint=${layout.executable};请确认上游包版本后再同步 build_support/package-layout.json`, + ); + } +} + +/// 缓存 key 的锁定信息:上游 package-lock 的 resolved + integrity。 +export function readLockedUpstream( + declaration, + target, + lockfilePath = path.join(REPO_ROOT, 'package-lock.json'), +) { + const layout = codexLayout(declaration, target); + const lockfile = JSON.parse(readFileSync(lockfilePath, 'utf8')); + const entry = + lockfile.packages?.[`node_modules/@openai/codex-${layout.platform}`]; + if (!entry?.resolved || !entry?.integrity) { + fail( + `package-lock.json 缺少 @openai/codex-${layout.platform} 的 resolved/integrity;请先在仓库根目录执行 npm ci`, + ); + } + return { resolved: entry.resolved, integrity: entry.integrity }; +} + +function serializeManifest(declaration, layout, hashes) { + const files = {}; + for (const relative of [...layout.files].sort()) { + files[relative] = hashes.get(relative); + } + return `${JSON.stringify( + { + files, + platform: layout.platform, + schemaVersion: declaration.codex.manifestSchema, + version: `${declaration.codex.cliVersionPrefix}${declaration.codex.version}`, + }, + null, + 2, + )}\n`; +} + +function noticeSourceFor(declaration, target) { + const notice = declaration.codex.noticeSources.find((entry) => + entry.targets.includes(target), + ); + if (!notice) { + fail(`随包资源声明缺少目标 ${target} 的第三方声明来源`); + } + return notice; +} + +function unitPathOf(destinationRoot, declaration, unit) { + return path.join( + destinationRoot, + declaration.codex.resourceDirectory, + unit.directory, + ); +} + +function targetPathWithin(unitPath, declaration, unit, target) { + const layout = codexLayout(declaration, target); + const relative = path.relative(unit.directory, layout.directory); + return relative ? path.join(unitPath, relative) : unitPath; +} + +/// 该替换单位允许出现的顶层条目:整目录分组下是各架构子目录,单目标下是组件首段路径 + 第三方声明 + 清单。 +function allowedUnitEntries(declaration, unit) { + const allowed = new Set(); + for (const member of unit.targets) { + const relativeDirectory = path.relative( + unit.directory, + member.layout.directory, + ); + if (relativeDirectory) { + allowed.add(relativeDirectory.split(path.sep)[0]); + continue; + } + for (const relative of member.layout.files) { + allowed.add(relative.split('/')[0]); + } + allowed.add(declaration.codex.noticeFileName); + allowed.add(declaration.codex.manifestFileName); + } + return allowed; +} + +function assertOwnedUnit(unitPath, declaration, unit) { + if (!existsSync(unitPath)) { + return; + } + const allowed = allowedUnitEntries(declaration, unit); + for (const entry of readdirSync(unitPath)) { + const entryPath = path.join(unitPath, entry); + if (!allowed.has(entry)) { + fail( + `随包资源目录被非本工具内容占用:${entryPath};请人工确认后删除该目录再重试`, + ); + } + } +} + +/// 期望产物:每个目标的组件摘要与清单文本。 +function desiredCodex(declaration, unit, roots) { + const desired = new Map(); + for (const member of unit.targets) { + const source = findCodexSource(declaration, member.target, roots); + validateUpstreamMetadata(declaration, member.target, source); + const hashes = new Map(); + for (const relative of member.layout.files) { + hashes.set(relative, sha256File(path.join(source, relative))); + } + desired.set(member.target, { + source, + files: new Map( + [...hashes].map(([relative, digest]) => [ + relative, + { sha256: digest, size: statSync(path.join(source, relative)).size }, + ]), + ), + manifest: serializeManifest(declaration, member.layout, hashes), + }); + } + return desired; +} + +function unitMatchesExisting(unitPath, declaration, unit, desired) { + if (!existsSync(unitPath)) { + return false; + } + const allowed = allowedUnitEntries(declaration, unit); + for (const entry of readdirSync(unitPath)) { + if (!allowed.has(entry)) { + return false; + } + } + for (const member of unit.targets) { + const expected = desired.get(member.target); + const targetDir = targetPathWithin( + unitPath, + declaration, + unit, + member.target, + ); + const manifestPath = path.join( + targetDir, + declaration.codex.manifestFileName, + ); + if ( + !existsSync(manifestPath) || + readFileSync(manifestPath, 'utf8') !== expected.manifest + ) { + return false; + } + for (const [relative, file] of expected.files) { + const filePath = path.join(targetDir, relative); + if (!existsSync(filePath)) { + return false; + } + const info = statSync(filePath); + if (info.size !== file.size || sha256File(filePath) !== file.sha256) { + return false; + } + if ( + process.platform !== 'win32' && + relative === member.layout.executable && + (info.mode & 0o111) === 0 + ) { + return false; + } + } + } + return true; +} + +function recordEntryFor(unitPath, declaration, unit, desired) { + const manifests = {}; + const sizes = {}; + for (const member of unit.targets) { + const expected = desired.get(member.target); + const targetDir = targetPathWithin( + unitPath, + declaration, + unit, + member.target, + ); + manifests[member.target] = sha256Text(expected.manifest); + sizes[member.layout.directory] = Object.fromEntries( + [...expected.files].map(([relative, file]) => [relative, file.size]), + ); + void targetDir; + } + return { manifests, sizes }; +} + +function unitRecordMatches(unitPath, declaration, unit, recorded) { + if (!recorded) { + return false; + } + for (const member of unit.targets) { + const targetDir = targetPathWithin( + unitPath, + declaration, + unit, + member.target, + ); + const manifestPath = path.join( + targetDir, + declaration.codex.manifestFileName, + ); + if (!existsSync(manifestPath)) { + return false; + } + if ( + sha256Text(readFileSync(manifestPath, 'utf8')) !== + recorded.manifests?.[member.target] + ) { + return false; + } + const expectedSizes = recorded.sizes?.[member.layout.directory]; + if (!expectedSizes) { + return false; + } + for (const relative of member.layout.files) { + const file = path.join(targetDir, relative); + if ( + !existsSync(file) || + statSync(file).size !== expectedSizes[relative] + ) { + return false; + } + } + } + return true; +} + +function buildUnitInto( + stagingPath, + declaration, + unit, + desired, + unitPath, + destinationRoot, +) { + for (const member of unit.targets) { + const expected = desired.get(member.target); + const targetDir = targetPathWithin( + stagingPath, + declaration, + unit, + member.target, + ); + for (const relative of member.layout.files) { + copyFilePreservingMode( + path.join(expected.source, relative), + path.join(targetDir, relative), + ); + } + const notice = noticeSourceFor(declaration, member.target); + const noticeDestination = path.join( + targetDir, + declaration.codex.noticeFileName, + ); + const noticeSourcePath = notice.preserve + ? path.join( + targetPathWithin(unitPath, declaration, unit, member.target), + declaration.codex.noticeFileName, + ) + : path.join(destinationRoot, notice.source); + if (!existsSync(noticeSourcePath)) { + fail(`第三方声明来源缺失:${noticeSourcePath}`); + } + copyFilePreservingMode(noticeSourcePath, noticeDestination); + writeFileSync( + path.join(targetDir, declaration.codex.manifestFileName), + expected.manifest, + ); + } +} + +function stageAtomically(unitPath, builder) { + const suffix = `${process.pid}-${randomBytes(4).toString('hex')}`; + const stagingPath = `${unitPath}-staging-${suffix}`; + const backupPath = `${unitPath}-backup-${suffix}`; + rmSync(stagingPath, { recursive: true, force: true }); + rmSync(backupPath, { recursive: true, force: true }); + mkdirSync(stagingPath, { recursive: true }); + try { + builder(stagingPath); + } catch (error) { + rmSync(stagingPath, { recursive: true, force: true }); + throw error; + } + + const hadPrevious = existsSync(unitPath); + try { + if (hadPrevious) { + renameSync(unitPath, backupPath); + } + renameSync(stagingPath, unitPath); + } catch (error) { + let restored = !hadPrevious; + if (hadPrevious && existsSync(backupPath)) { + try { + if (existsSync(unitPath)) { + rmSync(unitPath, { recursive: true, force: true }); + } + renameSync(backupPath, unitPath); + restored = true; + } catch { + restored = false; + } + } + fail( + `替换 ${unitPath} 失败(${error.code ?? error.message}):staging 保留在 ${stagingPath};旧资源${restored ? '已恢复' : '恢复失败,请检查 ' + backupPath},确认没有并发进程后重试`, + ); + } + rmSync(backupPath, { recursive: true, force: true }); +} + +function prepareCodex({ + declaration, + target, + destinationRoot, + roots, + lockfilePath, + record, + dryRun, +}) { + const unit = stagingUnit(declaration, target); + const label = `codex ${unit.targets.map((member) => member.target).join('+')}`; + const unitPath = unitPathOf(destinationRoot, declaration, unit); + const key = [ + `layoutVersion=${declaration.layoutVersion}`, + ...unit.targets.map( + (member) => + `${member.target}:${readLockedUpstream(declaration, member.target, lockfilePath).integrity}`, + ), + ].join('|'); + const recorded = record.codex?.[unit.directory]; + if ( + recorded?.key === key && + unitRecordMatches(unitPath, declaration, unit, recorded) + ) { + return { summary: `${label} 命中缓存(未写入)`, record: undefined }; + } + + const desired = desiredCodex(declaration, unit, roots); + const entry = { + key, + ...recordEntryFor(unitPath, declaration, unit, desired), + }; + if (unitMatchesExisting(unitPath, declaration, unit, desired)) { + return { summary: `${label} 命中缓存(内容一致,未写入)`, record: entry }; + } + if (dryRun) { + return { + summary: `${label} 需要重新生成(dry-run 未写入)`, + record: undefined, + }; + } + assertOwnedUnit(unitPath, declaration, unit); + stageAtomically(unitPath, (staging) => + buildUnitInto( + staging, + declaration, + unit, + desired, + unitPath, + destinationRoot, + ), + ); + return { + summary: `${label} 重新生成(${unit.targets.length} 个架构,写入 ${declaration.codex.resourceDirectory}/${unit.directory})`, + record: entry, + }; +} + +/// Claude Agent SDK sidecar 的声明目标;未声明的目标不随包 sidecar(Linux 等平台直接跳过)。 +function claudeAgentLayout(declaration, target) { + return ( + declaration.claudeAgent.targets.find((entry) => entry.target === target) ?? + null + ); +} + +/// 上游包目录:按声明顺序在 app / 仓库根的 node_modules 下取第一个命中的候选。 +function requireClaudeAgentPackage(declaration, roots, packageName, label) { + const candidates = declaration.claudeAgent.sourceRoots.map((rootName) => { + const root = roots[rootName]; + if (!root) { + fail(`未知的声明 sourceRoots 取值:${rootName}`); + } + return path.join( + root, + declaration.claudeAgent.nodeModulesDirectory, + packageName, + ); + }); + const found = candidates.find((candidate) => existsSync(candidate)); + if (!found) { + fail( + `${label}缺失;请先在仓库根目录执行 npm ci(已检查:${candidates.join(';')})`, + ); + } + return found; +} + +/// 上游包版本必须与声明一致:静默发旧 SDK 的代价是 sidecar 与客户端说不上话。 +function validateClaudeAgentPackageVersion(packageDirectory, declared, label) { + const metadataFile = path.join( + packageDirectory, + declared.sdkPackageMetadataFileName, + ); + let metadata; + try { + metadata = JSON.parse(readFileSync(metadataFile, 'utf8')); + } catch (error) { + fail( + `${label} 元数据不可读或不是合法 JSON:${metadataFile}(${error.message})`, + ); + } + if (metadata.version !== declared.version) { + fail( + `${label} 版本与声明不一致:${String(metadata.version)}(期望 ${declared.version},${metadataFile});请同步更新 build_support/package-layout.json`, + ); + } +} + +/// 期望产物:仓库里的 sidecar 入口 + 上游 SDK 包 + 平台原生运行时包(随包相对路径 → 来源文件)。 +function claudeAgentDesiredFiles(declaration, layout, roots) { + const declared = declaration.claudeAgent; + const scope = declared.sdkPackageName.split('/')[0]; + const entrySource = path.join(roots.app, declared.entryRelativePath); + if (!existsSync(entrySource)) { + fail(`Claude Agent SDK sidecar 入口缺失:${entrySource}`); + } + const sdkSource = requireClaudeAgentPackage( + declaration, + roots, + declared.sdkPackageName, + 'Claude Agent SDK 上游包', + ); + if (!existsSync(path.join(sdkSource, declared.sdkEntryFileName))) { + fail( + `Claude Agent SDK 上游包缺少入口 ${declared.sdkEntryFileName}:${sdkSource}`, + ); + } + const runtimePackage = `${scope}/${layout.runtimePackage}`; + const runtimeSource = requireClaudeAgentPackage( + declaration, + roots, + runtimePackage, + `Claude Agent SDK 原生运行时(${layout.runtimePackage})`, + ); + if (!existsSync(path.join(runtimeSource, layout.runtimeFileName))) { + fail( + `Claude Agent SDK 原生运行时缺少可执行文件:${path.join(runtimeSource, layout.runtimeFileName)}`, + ); + } + validateClaudeAgentPackageVersion(sdkSource, declared, 'Claude Agent SDK'); + validateClaudeAgentPackageVersion( + runtimeSource, + declared, + 'Claude Agent SDK 原生运行时', + ); + + const files = new Map([ + [ + declared.entryFileName, + { + source: entrySource, + executable: false, + }, + ], + ]); + for (const relative of walkFiles( + declared, + sdkSource, + 'Claude Agent SDK 上游包', + )) { + files.set( + `${declared.nodeModulesDirectory}/${declared.sdkPackageName}/${relative}`, + { + source: path.join(sdkSource, relative), + executable: false, + }, + ); + } + for (const relative of walkFiles( + declared, + runtimeSource, + 'Claude Agent SDK 原生运行时', + )) { + files.set( + `${declared.nodeModulesDirectory}/${runtimePackage}/${relative}`, + { + source: path.join(runtimeSource, relative), + executable: relative === layout.runtimeFileName, + }, + ); + } + return files; +} + +/// 已落盘目录里的全部普通文件(相对路径);不套用复制规则,越界文件必须能被看见。 +function collectClaudeAgentFiles(root) { + const files = []; + const stack = [['', root]]; + while (stack.length > 0) { + const [prefix, directory] = stack.pop(); + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const entryPath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + fail(`Claude Agent SDK 随包资源不允许符号链接:${entryPath}`); + } + if (entry.isDirectory()) { + stack.push([relative, entryPath]); + } else if (entry.isFile()) { + files.push(relative); + } + } + } + return files.sort(); +} + +/// 随包目录与期望产物是否一致:路径集合、尺寸、可执行位,必要时再逐文件比对内容。 +function claudeAgentTreeMatches(unitPath, files, { compareContent }) { + if (!existsSync(unitPath)) { + return false; + } + const expected = [...files.keys()].sort(); + const actual = collectClaudeAgentFiles(unitPath); + if ( + actual.length !== expected.length || + actual.some((relative, index) => relative !== expected[index]) + ) { + return false; + } + for (const [relative, entry] of files) { + const staged = path.join(unitPath, ...relative.split('/')); + const sourceInfo = statSync(entry.source); + const stagedInfo = statSync(staged); + if (sourceInfo.size !== stagedInfo.size) { + return false; + } + // Windows 的 mode 位不表达可执行语义;其它平台要求声明为可执行的文件确实带可执行位。 + if ( + process.platform !== 'win32' && + entry.executable && + (stagedInfo.mode & 0o111) === 0 + ) { + return false; + } + if (compareContent && !sameFileContent(entry.source, staged)) { + return false; + } + } + return true; +} + +/// 只允许替换由本工具创建的目录:出现白名单外的顶层条目即失败关闭。 +function assertOwnedClaudeAgentRoot(unitPath, declared) { + if (!existsSync(unitPath)) { + return; + } + const allowed = new Set([ + declared.entryFileName, + declared.nodeModulesDirectory, + ]); + for (const entry of readdirSync(unitPath)) { + if (!allowed.has(entry)) { + fail( + `Claude Agent SDK sidecar 随包目录被非本工具内容占用:${path.join(unitPath, entry)};请人工确认后删除该目录再重试`, + ); + } + } +} + +/// Claude Agent SDK sidecar:入口来自仓库源码、SDK 与原生运行时来自上游 npm 包。 +/// 整目录原子替换;命中缓存时不写任何文件。缓存 key = 声明版本 + 目标 + 入口摘要, +/// 上游包按 npm 版本锁定,因此快速路径只比路径集合、尺寸与可执行位(与 codex 的缓存口径一致)。 +function prepareClaudeAgent({ + declaration, + target, + destinationRoot, + roots, + record, + dryRun, +}) { + const layout = claudeAgentLayout(declaration, target); + if (!layout) { + return { + summary: `claude-agent 跳过(目标 ${target} 不适用)`, + record: undefined, + }; + } + const declared = declaration.claudeAgent; + const label = `claude-agent ${target}`; + const unitPath = path.join(destinationRoot, declared.resourceDirectory); + const files = claudeAgentDesiredFiles(declaration, layout, roots); + const key = [ + `layoutVersion=${declaration.layoutVersion}`, + `target=${target}`, + `version=${declared.version}`, + `entry=${sha256File(path.join(roots.app, declared.entryRelativePath))}`, + ].join('|'); + assertOwnedClaudeAgentRoot(unitPath, declared); + if ( + record.claudeAgent?.[target]?.key === key && + claudeAgentTreeMatches(unitPath, files, { compareContent: false }) + ) { + return { summary: `${label} 命中缓存(未写入)`, record: undefined }; + } + if (claudeAgentTreeMatches(unitPath, files, { compareContent: true })) { + return { + summary: `${label} 命中缓存(内容一致,未写入)`, + record: { key }, + }; + } + if (dryRun) { + return { + summary: `${label} 需要重新生成(dry-run 未写入)`, + record: undefined, + }; + } + stageAtomically(unitPath, (staging) => { + for (const [relative, entry] of files) { + const destination = path.join(staging, ...relative.split('/')); + copyFilePreservingMode(entry.source, destination); + // 原生运行时必须可执行:上游 tarball 的权限位偶有丢失,这里按声明兜底补一次。 + if (entry.executable && process.platform !== 'win32') { + chmodSync(destination, 0o755); + } + } + }); + return { + summary: `${label} 重新生成(${files.size} 个文件,写入 ${declared.resourceDirectory})`, + record: { key }, + }; +} + +export function pluginDirectories(declaration, repoRoot) { + const root = path.join(repoRoot, declaration.plugins.sourceDirectory); + if (!existsSync(root)) { + fail(`插件工作区缺失:${root}`); + } + return readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()) + .map((entry) => ({ name: entry.name, root: path.join(root, entry.name) })) + .filter((plugin) => + existsSync(path.join(plugin.root, declaration.plugins.manifestFileName)), + ); +} + +function subdirectoryAppliesToPlugin(subdirectory, pluginName) { + return !subdirectory.plugin || subdirectory.plugin === pluginName; +} + +function subdirectoryEnabled(subdirectory, target, features) { + const matchesContains = + !subdirectory.targetContains?.length || + subdirectory.targetContains.some((needle) => target.includes(needle)); + const matchesTarget = + !subdirectory.targets?.length || subdirectory.targets.includes(target); + const matchesFeatures = (subdirectory.features ?? []).every((name) => + features.has(name), + ); + return matchesContains && matchesTarget && matchesFeatures; +} + +function collectTreeFiles(root, label) { + const files = []; + const stack = [['', root]]; + while (stack.length > 0) { + const [prefix, directory] = stack.pop(); + if (!existsSync(directory)) { + continue; + } + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const entryPath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + fail(`${label}不允许符号链接:${entryPath}`); + } + if (entry.isDirectory()) { + stack.push([relative, entryPath]); + } else if (entry.isFile()) { + files.push(relative); + } + } + } + return files.sort(); +} + +/// 复制规则由各 section 自带(plugins / claudeAgent),同名语义、同序判定。 +function skipDirectory(rules, name) { + return ( + rules.skipDirectoryNames.includes(name) || + rules.skipDirectoryNamePrefixes.some((prefix) => name.startsWith(prefix)) + ); +} + +function skipFileName(rules, name) { + return ( + rules.skipFileNamePrefixes.some((prefix) => name.startsWith(prefix)) || + rules.skipFileNameFragments.some((fragment) => name.includes(fragment)) + ); +} + +function walkFiles(rules, root, label) { + const files = []; + const stack = [['', root]]; + while (stack.length > 0) { + const [prefix, directory] = stack.pop(); + if (!existsSync(directory)) { + continue; + } + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const entryPath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + fail(`${label}不允许符号链接:${entryPath}`); + } + if (entry.isDirectory()) { + if (!skipDirectory(rules, entry.name)) { + stack.push([relative, entryPath]); + } + } else if (entry.isFile() && !skipFileName(rules, entry.name)) { + files.push(relative); + } + } + } + return files.sort(); +} + +function copyPluginSubdirectory(declaration, source, destination) { + if (!existsSync(source)) { + return; + } + mkdirSync(destination, { recursive: true }); + for (const entry of readdirSync(source, { withFileTypes: true })) { + const entrySource = path.join(source, entry.name); + const entryDestination = path.join(destination, entry.name); + if (entry.isSymbolicLink()) { + fail(`插件资源不允许符号链接:${entrySource}`); + } + if (entry.isDirectory()) { + if (!skipDirectory(declaration.plugins, entry.name)) { + copyPluginSubdirectory(declaration, entrySource, entryDestination); + } + } else if ( + entry.isFile() && + !skipFileName(declaration.plugins, entry.name) + ) { + copyFilePreservingMode(entrySource, entryDestination); + } + } +} + +function pluginSourceFingerprint(declaration, plugins, target, features) { + const lines = []; + for (const plugin of plugins) { + for (const subdirectory of declaration.plugins.subdirectories) { + if ( + !subdirectoryAppliesToPlugin(subdirectory, plugin.name) || + !subdirectoryEnabled(subdirectory, target, features) + ) { + continue; + } + const root = path.join(plugin.root, subdirectory.path); + for (const file of walkFiles(declaration.plugins, root, '插件资源')) { + const absolute = path.join(root, file); + const info = statSync(absolute); + // prepared 产物由准备步骤无条件复制,mtime 会变;用内容哈希保证幂等判断稳定。 + const stamp = + subdirectory.origin === 'prepared' + ? `sha256:${sha256File(absolute)}` + : `${info.size}:${Math.round(info.mtimeMs)}`; + lines.push(`${plugin.name}/${subdirectory.path}/${file}:${stamp}`); + } + } + const manifest = path.join( + plugin.root, + declaration.plugins.manifestFileName, + ); + const info = statSync(manifest); + lines.push( + `${plugin.name}/plugin.json:${info.size}:${Math.round(info.mtimeMs)}`, + ); + for (const staging of declaration.plugins.libraryStaging ?? []) { + if ( + plugin.name !== staging.plugin || + !libraryStagingEnabled(staging, target, features) + ) { + continue; + } + for (const relative of staging.files) { + const file = path.join( + plugin.root, + staging.sourceSubdirectory, + relative, + ); + lines.push( + `${plugin.name}/${staging.sourceSubdirectory}/${relative}:${ + existsSync(file) ? `sha256:${sha256File(file)}` : 'missing' + }`, + ); + } + } + for (const payload of declaration.plugins.nativePayloads ?? []) { + if ( + plugin.name !== payload.plugin || + !nativePayloadEnabled(payload, target, features) + ) { + continue; + } + const delivered = path.join( + plugin.root, + payload.destinationSubdirectory, + payload.destinationFileName ?? payload.sourceFileName, + ); + lines.push( + `${plugin.name}/${payload.destinationSubdirectory}:${ + existsSync(delivered) ? `sha256:${sha256File(delivered)}` : 'missing' + }`, + ); + } + } + return sha256Text(lines.join('\n')); +} + +function pluginTreeMatches( + declaration, + plugins, + target, + features, + destination, +) { + if (!existsSync(destination)) { + return false; + } + const allowedFiles = new Set(); + for (const plugin of plugins) { + const pluginDestination = path.join(destination, plugin.name); + const pluginPrefix = `${plugin.name}/`; + allowedFiles.add(`${pluginPrefix}${declaration.plugins.manifestFileName}`); + if ( + !existsSync( + path.join(pluginDestination, declaration.plugins.manifestFileName), + ) + ) { + return false; + } + for (const subdirectory of declaration.plugins.subdirectories) { + if (!subdirectoryAppliesToPlugin(subdirectory, plugin.name)) { + continue; + } + const stagedDirectory = path.join(pluginDestination, subdirectory.path); + if (!subdirectoryEnabled(subdirectory, target, features)) { + if (existsSync(stagedDirectory)) { + return false; + } + continue; + } + const relativePrefix = `${plugin.name}/${subdirectory.path}/`; + const sourceRoot = path.join(plugin.root, subdirectory.path); + if (subdirectory.origin !== 'source') { + if (existsSync(sourceRoot) && !existsSync(stagedDirectory)) { + return false; + } + for (const relative of walkFiles( + declaration.plugins, + sourceRoot, + '插件资源', + )) { + const staged = path.join(stagedDirectory, relative); + if (!existsSync(staged)) { + return false; + } + allowedFiles.add(`${relativePrefix}${relative}`); + } + continue; + } + for (const relative of walkFiles( + declaration.plugins, + sourceRoot, + '插件资源', + )) { + const source = path.join(sourceRoot, relative); + const staged = path.join( + pluginDestination, + subdirectory.path, + relative, + ); + allowedFiles.add(`${relativePrefix}${relative}`); + if (!existsSync(staged)) { + return false; + } + if (statSync(source).size !== statSync(staged).size) { + return false; + } + if (sha256File(source) !== sha256File(staged)) { + return false; + } + } + } + for (const staging of declaration.plugins.libraryStaging ?? []) { + if ( + plugin.name === staging.plugin && + libraryStagingEnabled(staging, target, features) + ) { + for (const relative of staging.files) { + const staged = path.join( + pluginDestination, + staging.sourceSubdirectory, + relative, + ); + if (!existsSync(staged)) { + return false; + } + allowedFiles.add( + `${plugin.name}/${staging.sourceSubdirectory}/${relative}`, + ); + } + } + } + for (const payload of declaration.plugins.nativePayloads ?? []) { + if ( + plugin.name === payload.plugin && + nativePayloadEnabled(payload, target, features) + ) { + const relative = `${payload.destinationSubdirectory}/${payload.destinationFileName ?? payload.sourceFileName}`; + if (!existsSync(path.join(pluginDestination, relative))) { + return false; + } + allowedFiles.add(`${plugin.name}/${relative}`); + } + } + } + return collectTreeFiles(destination, '插件随包目录').every((relative) => + allowedFiles.has(relative), + ); +} + +function assertOwnedPluginRoot(destination, plugins) { + if (!existsSync(destination)) { + return; + } + const known = new Set(plugins.map((plugin) => plugin.name)); + for (const entry of readdirSync(destination)) { + if (!known.has(entry)) { + fail( + `插件随包目录被非本工具内容占用:${path.join(destination, entry)};请人工确认后删除该目录再重试`, + ); + } + } +} + +/// 声明的准备步骤:需要外部工具链或同一次 cargo 构建才能产出的随包内容。 +function prepareStepsReferencedBy(declaration, target, features) { + const required = new Set(); + for (const subdirectory of declaration.plugins.subdirectories) { + if ( + subdirectory.origin === 'prepared' && + subdirectoryEnabled(subdirectory, target, features) + ) { + required.add(subdirectory.prepare); + } + } + for (const staging of declaration.plugins.libraryStaging ?? []) { + if (libraryStagingEnabled(staging, target, features)) { + required.add(staging.prepare); + } + } + for (const payload of declaration.plugins.nativePayloads ?? []) { + if (nativePayloadEnabled(payload, target, features)) { + required.add(payload.prepare); + } + } + return required; +} + +function libraryStagingEnabled(staging, target, features) { + return ( + (!staging.targets?.length || staging.targets.includes(target)) && + (staging.features ?? []).every((name) => features.has(name)) + ); +} + +function nativePayloadEnabled(payload, target, features) { + return ( + (!payload.targets?.length || payload.targets.includes(target)) && + (payload.features ?? []).every((name) => features.has(name)) + ); +} + +/// 指纹覆盖声明的根目录(跳过排除目录),与构建脚本原先的规则一致:按相对路径排序后逐文件哈希。 +function fingerprintSources(repoRoot, step) { + const { roots, excludeDirectoryNames } = step.fingerprint; + const lines = []; + for (const root of roots) { + const absoluteRoot = path.join(repoRoot, step.workingDirectory, root); + for (const relative of collectFingerprintFiles( + absoluteRoot, + excludeDirectoryNames, + )) { + const file = path.join(absoluteRoot, relative); + const info = statSync(file); + lines.push(`${relative}\u0000${info.size}\u0000${sha256File(file)}`); + } + } + return sha256Text(lines.join('\n')); +} + +function collectFingerprintFiles(root, excludeDirectoryNames, prefix = '') { + const files = []; + if (!existsSync(root)) { + return files; + } + for (const entry of readdirSync(root, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isSymbolicLink()) { + fail(`准备步骤源码不允许符号链接:${path.join(root, entry.name)}`); + } + if (entry.isDirectory()) { + if (!excludeDirectoryNames.includes(entry.name)) { + files.push( + ...collectFingerprintFiles( + path.join(root, entry.name), + excludeDirectoryNames, + relative, + ), + ); + } + } else if (entry.isFile()) { + files.push(relative); + } + } + return files.sort(); +} + +function requiredOutputsPresent(repoRoot, step) { + return step.requiredOutputs.every((relative) => { + const file = path.join(repoRoot, relative); + return ( + existsSync(file) && statSync(file).isFile() && statSync(file).size > 0 + ); + }); +} + +function defaultRunCommand({ program, args, cwd, removeEnvironment }) { + const environment = { ...process.env }; + for (const name of removeEnvironment ?? []) { + delete environment[name]; + } + const result = spawnSync(program, args, { + cwd, + env: environment, + stdio: 'inherit', + }); + if (result.error) { + throw new PrepareError(`执行 ${program} 失败:${result.error.message}`); + } + if (result.status !== 0) { + throw new PrepareError(`${program} 退出码 ${result.status}`); + } +} + +/// 按声明产出「已准备」随包内容;命中指纹且必需产物齐全时零写入。 +export function ensurePreparedArtifacts({ + declaration, + repoRoot, + srcTauriRoot, + target, + features, + profile = 'debug', + runCommand = defaultRunCommand, + log = () => {}, + dryRun = false, +}) { + const required = prepareStepsReferencedBy(declaration, target, features); + const steps = new Map( + (declaration.plugins.prepareSteps ?? []).map((step) => [step.name, step]), + ); + for (const name of required) { + const step = steps.get(name); + if (!step) { + fail(`声明引用了未定义的准备步骤:${name}`); + } + const stampPath = step.fingerprint + ? path.join( + repoRoot, + step.workingDirectory, + step.fingerprint.stampRelativePath, + ) + : undefined; + if (step.fingerprint && step.requiredOutputs.length > 0) { + const expected = fingerprintSources(repoRoot, step); + const current = existsSync(stampPath) + ? readFileSync(stampPath, 'utf8').trim() + : ''; + if (current === expected && requiredOutputsPresent(repoRoot, step)) { + log(`${name} 命中缓存(指纹一致)`); + continue; + } + } + if (dryRun) { + log(`${name} 需要重新构建(dry-run 未执行)`); + continue; + } + if (step.kind === 'powershell') { + runCommand({ + program: 'powershell.exe', + args: [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + path.join(repoRoot, step.workingDirectory, step.scriptFileName), + ], + cwd: path.join(repoRoot, step.workingDirectory), + removeEnvironment: step.removeEnvironment ?? [], + }); + } else if (step.kind === 'cargo') { + runCommand({ + program: 'cargo', + args: [ + 'build', + '--manifest-path', + path.join(repoRoot, step.packageDirectory, 'Cargo.toml'), + '--target-dir', + path.join(srcTauriRoot, 'target'), + '--target', + target, + ...(profile === 'release' ? ['--release'] : []), + ...(step.features?.length + ? ['--features', step.features.join(',')] + : []), + ], + cwd: repoRoot, + removeEnvironment: [], + }); + } else { + fail(`未实现的准备步骤类型:${step.kind}`); + } + if (!requiredOutputsPresent(repoRoot, step)) { + const missing = step.requiredOutputs.filter( + (relative) => !existsSync(path.join(repoRoot, relative)), + ); + fail(`${name} 未产出必需文件:${missing.join('、') || '(未知)'}`); + } + if (stampPath && step.fingerprint) { + mkdirSync(path.dirname(stampPath), { recursive: true }); + writeFileSync(stampPath, `${fingerprintSources(repoRoot, step)}\n`); + } + log(`${name} 已构建`); + } +} + +/// 复制声明为已准备的随包子目录与随包库。 +function copyPreparedPayloads({ + declaration, + repoRoot, + staging, + target, + features, +}) { + for (const plugin of pluginDirectories(declaration, repoRoot)) { + for (const subdirectory of declaration.plugins.subdirectories) { + if ( + subdirectory.origin !== 'prepared' || + !subdirectoryEnabled(subdirectory, target, features) + ) { + continue; + } + copyPluginSubdirectory( + declaration, + path.join(plugin.root, subdirectory.path), + path.join(staging, plugin.name, subdirectory.path), + ); + } + for (const stagingEntry of declaration.plugins.libraryStaging ?? []) { + if ( + plugin.name !== stagingEntry.plugin || + !libraryStagingEnabled(stagingEntry, target, features) + ) { + continue; + } + for (const relative of stagingEntry.files) { + copyFilePreservingMode( + path.join(plugin.root, stagingEntry.sourceSubdirectory, relative), + path.join( + staging, + plugin.name, + stagingEntry.sourceSubdirectory, + relative, + ), + ); + } + } + } +} + +/// 逐字节相等且尺寸一致;目标不存在即视为不同。 +function sameFileContent(source, destination) { + if (!existsSync(destination)) { + return false; + } + const sourceInfo = statSync(source); + const destinationInfo = statSync(destination); + return ( + sourceInfo.size === destinationInfo.size && + Buffer.compare(readFileSync(source), readFileSync(destination)) === 0 + ); +} + +/// 内容一致时不重写(保住时间戳与「命中缓存零写入」口径)。 +function copyFilePreservingModeIfChanged(source, destination) { + if (sameFileContent(source, destination)) { + return; + } + copyFilePreservingMode(source, destination); +} + +/// Cocos bridge 的 dll 既要进插件工作区(唯一真源),也要进随包目录。 +function copyNativePayloads({ + declaration, + repoRoot, + srcTauriRoot, + staging, + target, + features, + profile, +}) { + for (const payload of declaration.plugins.nativePayloads ?? []) { + if (!nativePayloadEnabled(payload, target, features)) { + continue; + } + const candidates = [ + path.join( + srcTauriRoot, + 'target', + target, + profile, + 'deps', + payload.sourceFileName, + ), + path.join( + srcTauriRoot, + 'target', + target, + profile, + payload.sourceFileName, + ), + path.join( + srcTauriRoot, + 'target', + profile, + 'deps', + payload.sourceFileName, + ), + path.join(srcTauriRoot, 'target', profile, payload.sourceFileName), + ]; + const source = candidates.find((candidate) => existsSync(candidate)); + if (!source) { + fail( + `Cocos bridge native payload 未构建:${candidates.map((candidate) => path.relative(repoRoot, candidate)).join(';')}`, + ); + } + const destinationName = + payload.destinationFileName ?? payload.sourceFileName; + copyFilePreservingModeIfChanged( + source, + path.join( + repoRoot, + 'plugins', + payload.plugin, + payload.destinationSubdirectory, + destinationName, + ), + ); + copyFilePreservingModeIfChanged( + source, + path.join( + staging, + payload.plugin, + payload.destinationSubdirectory, + destinationName, + ), + ); + } +} + +function preparePlugins({ + declaration, + target, + destinationRoot, + repoRoot, + features, + plugins, + record, + profile, + dryRun, +}) { + const destination = path.join( + destinationRoot, + declaration.plugins.destinationDirectory, + ); + const fingerprint = pluginSourceFingerprint( + declaration, + plugins, + target, + features, + ); + const upToDate = + record.plugins?.fingerprint === fingerprint && + pluginTreeMatches(declaration, plugins, target, features, destination); + // dry-run 必须零写入:只做只读判断就返回。 + if (dryRun) { + return { + summary: upToDate + ? `plugins 命中缓存(未写入,${plugins.length} 个插件)` + : `plugins 需要重新生成(dry-run 未写入,${plugins.length} 个插件)`, + record: undefined, + }; + } + // 所有权断言先于任何写入(含下面的预缓存交付)。 + assertOwnedPluginRoot(destination, plugins); + // payload 交付不能排在缓存短路之后:否则「先默认构建、之后开 injection」会把交付整条跳过。 + if (existsSync(destination)) { + copyNativePayloads({ + declaration, + repoRoot, + srcTauriRoot: destinationRoot, + staging: destination, + target, + features, + profile, + }); + } + if (upToDate) { + return { + summary: `plugins 命中缓存(未写入,${plugins.length} 个插件)`, + record: undefined, + }; + } + stageAtomically(destination, (staging) => { + for (const plugin of plugins) { + const pluginDestination = path.join(staging, plugin.name); + copyFilePreservingMode( + path.join(plugin.root, declaration.plugins.manifestFileName), + path.join(pluginDestination, declaration.plugins.manifestFileName), + ); + for (const subdirectory of declaration.plugins.subdirectories) { + if (subdirectory.origin !== 'source') { + continue; + } + if (!subdirectoryEnabled(subdirectory, target, features)) { + continue; + } + copyPluginSubdirectory( + declaration, + path.join(plugin.root, subdirectory.path), + path.join(pluginDestination, subdirectory.path), + ); + } + } + copyPreparedPayloads({ + declaration, + repoRoot, + staging, + target, + features, + }); + copyNativePayloads({ + declaration, + repoRoot, + srcTauriRoot: destinationRoot, + staging, + target, + features, + profile, + }); + }); + return { + summary: `plugins 重新生成(${plugins.length} 个插件,写入 ${declaration.plugins.destinationDirectory})`, + record: { + fingerprint: pluginSourceFingerprint( + declaration, + plugins, + target, + features, + ), + }, + }; +} +/// 准备全部随包资源;返回逐条汇总,供入口日志与测试断言。 +export function prepareBundledResources({ + target = resolveHostTarget(), + destinationRoot = SRC_TAURI_DIR, + declarationPath = DECLARATION_PATH, + features = new Set(defaultEditorFeatures(target)), + recordPath = RECORD_PATH, + lockfilePath = path.join(REPO_ROOT, 'package-lock.json'), + profile = 'debug', + runCommand = defaultRunCommand, + log = () => {}, + dryRun = false, + repoRoot = REPO_ROOT, + appRoot = path.dirname(destinationRoot), +} = {}) { + const declaration = readDeclaration(declarationPath); + const record = readRecord(recordPath); + const summaries = []; + let changed = false; + const codex = prepareCodex({ + declaration, + target, + destinationRoot, + roots: { app: appRoot, repo: repoRoot }, + lockfilePath, + record, + dryRun, + }); + summaries.push(codex.summary); + if (codex.record) { + record.codex = { + ...record.codex, + [stagingUnit(declaration, target).directory]: codex.record, + }; + changed = true; + } + if ( + declaration.plugins.targetContainsAny.some((needle) => + target.includes(needle), + ) + ) { + ensurePreparedArtifacts({ + declaration, + repoRoot, + srcTauriRoot: destinationRoot, + target, + features, + profile, + runCommand, + dryRun, + log, + }); + const plugins = preparePlugins({ + declaration, + target, + destinationRoot, + repoRoot, + features, + plugins: pluginDirectories(declaration, repoRoot), + record, + profile, + dryRun, + }); + summaries.push(plugins.summary); + if (plugins.record) { + record.plugins = plugins.record; + changed = true; + } + } else { + summaries.push(`plugins 跳过(目标 ${target} 不适用)`); + } + const claudeAgent = prepareClaudeAgent({ + declaration, + target, + destinationRoot, + roots: { app: appRoot, repo: repoRoot }, + record, + dryRun, + }); + summaries.push(claudeAgent.summary); + if (claudeAgent.record) { + record.claudeAgent = { + ...record.claudeAgent, + [target]: claudeAgent.record, + }; + changed = true; + } + if (changed && !dryRun) { + writeRecord(record, recordPath); + } + return summaries; +} + +function requireFlagValue(argv, index, flag) { + const value = argv[index + 1]; + if (value === undefined || String(value).startsWith('--')) { + fail( + `${flag} 缺少取值(例如 ${flag} );拒绝回退到宿主默认值,以免准备错目标`, + ); + } + return String(value); +} + +function parseArguments(argv) { + const args = { target: undefined, destinationRoot: undefined, dryRun: false }; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === '--target') { + args.target = requireFlagValue(argv, index, '--target'); + index += 1; + } else if (value === '--destination') { + args.destinationRoot = requireFlagValue(argv, index, '--destination'); + index += 1; + } else if (value === '--dry-run') { + args.dryRun = true; + } else if (value.startsWith('--features=')) { + args.features = new Set( + value.slice('--features='.length).split(',').filter(Boolean), + ); + } else if (value === '--features') { + args.features = new Set( + String(argv[index + 1] ?? '') + .split(',') + .filter(Boolean), + ); + index += 1; + } else { + fail(`未知参数:${value}`); + } + } + return args; +} + +function main(argv) { + const args = parseArguments(argv); + const summaries = prepareBundledResources({ + target: args.target ?? resolveHostTarget(), + destinationRoot: args.destinationRoot ?? SRC_TAURI_DIR, + ...(args.features ? { features: args.features } : {}), + dryRun: args.dryRun, + log: (line) => console.log(`[agc-resources] ${line}`), + }); + for (const summary of summaries) { + console.log(`[agc-resources] ${summary}`); + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + main(process.argv.slice(2)); + } catch (error) { + if (error instanceof PrepareError) { + console.error(`[agc-resources] ${error.message}`); + process.exit(1); + } + throw error; + } +} diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs new file mode 100644 index 000000000..c808ab43c --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -0,0 +1,1159 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { + DECLARATION_PATH, + findCodexSource, + pluginDirectories, + prepareBundledResources, + readDeclaration, + resolveHostTarget, + stagingUnit, +} from './prepare-bundled-resources.mjs'; + +const WINDOWS_TARGET = 'x86_64-pc-windows-msvc'; +const MAC_TARGET = 'aarch64-apple-darwin'; + +function sha256File(file) { + return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +/// 造一个最小工作区:app(含 node_modules 上游包)、repo(含 plugins 工作区)、lockfile。 +/// 造出声明里所有「已准备」产物:真实构建要 Windows 工具链,用例只需要文件在位。 +function writePrepareArtifacts(root, declaration) { + const created = []; + for (const step of declaration.plugins.prepareSteps ?? []) { + for (const relative of step.requiredOutputs) { + const file = path.join(root, relative); + if (fs.existsSync(file)) { + continue; + } + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `prepared ${relative}\n`); + created.push(relative); + } + } + return created; +} + +/// 记录调用的假执行器:默认把声明的必需产物造出来。 +function fakeRunCommand({ created = [], failOn = null } = {}) { + return (invocation) => { + created.push(`${invocation.program} ${(invocation.args ?? []).join(' ')}`); + if (failOn && invocation.program === failOn) { + throw new Error('fake run failure'); + } + }; +} + +function buildFixture({ + targets = [WINDOWS_TARGET], + plugins = true, + claudeAgent = true, +} = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-resources-')); + const appRoot = path.join(root, 'app'); + const repoRoot = path.join(root, 'repo'); + const destinationRoot = path.join(appRoot, 'src-tauri'); + const declaration = readDeclaration(DECLARATION_PATH); + const lockfile = { packages: {} }; + + for (const target of targets) { + const layout = declaration.codex.targets.find( + (entry) => entry.target === target, + ); + assert.ok(layout, `声明缺少目标 ${target}`); + const vendor = path.join( + appRoot, + `node_modules/@openai/codex-${layout.platform}/vendor/${target}`, + ); + for (const relative of layout.files) { + const file = path.join(vendor, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + relative === declaration.codex.packageMetadataFileName + ? `${JSON.stringify( + { + layoutVersion: declaration.codex.packageMetadata.layoutVersion, + version: declaration.codex.version, + target, + entrypoint: layout.executable, + resourcesDir: declaration.codex.packageMetadata.resourcesDir, + pathDir: declaration.codex.packageMetadata.pathDir, + }, + null, + 2, + )}\n` + : `component ${target} ${relative}\n`, + ); + if (relative === layout.executable) { + fs.chmodSync(file, 0o755); + } + } + lockfile.packages[`node_modules/@openai/codex-${layout.platform}`] = { + resolved: `https://registry.npmjs.org/@openai/codex-${layout.platform}/-/${layout.platform}.tgz`, + integrity: `sha512-${target}`, + }; + } + + fs.mkdirSync(path.join(destinationRoot, 'resources/codex'), { + recursive: true, + }); + for (const entry of declaration.codex.noticeSources) { + if (entry.preserve) { + continue; + } + const file = path.join(destinationRoot, entry.source); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, 'mac codex notice\n'); + } + if (targets.includes(WINDOWS_TARGET)) { + const tracked = path.join( + destinationRoot, + 'resources/codex/win-x64/NOTICE.md', + ); + fs.mkdirSync(path.dirname(tracked), { recursive: true }); + fs.writeFileSync(tracked, 'windows codex notice\n'); + } + + if (plugins) { + const pluginRoot = path.join(repoRoot, 'plugins/agc-demo-editor'); + fs.mkdirSync(path.join(pluginRoot, 'src'), { recursive: true }); + fs.mkdirSync(path.join(pluginRoot, 'panels'), { recursive: true }); + fs.mkdirSync(path.join(pluginRoot, 'target'), { recursive: true }); + fs.mkdirSync(path.join(pluginRoot, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, 'plugin.json'), + '{"name":"agc-demo-editor"}\n', + ); + fs.writeFileSync( + path.join(pluginRoot, 'src/entry.mjs'), + 'export const entry = 1;\n', + ); + fs.writeFileSync( + path.join(pluginRoot, 'panels/panel.html'), + '\n', + ); + fs.writeFileSync(path.join(pluginRoot, 'panels/panel.test.mjs'), 'test\n'); + fs.writeFileSync(path.join(pluginRoot, 'target/junk.rs'), 'junk\n'); + fs.writeFileSync(path.join(pluginRoot, '.git/HEAD'), 'ref\n'); + fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n'); + + for (const name of [ + 'agc-cocos-editor', + 'agc-unity-editor', + 'agc-godot-editor', + ]) { + const root = path.join(repoRoot, 'plugins', name); + fs.mkdirSync(path.join(root, 'src'), { + recursive: true, + }); + fs.writeFileSync(path.join(root, 'plugin.json'), `{"name":"${name}"}\n`); + fs.writeFileSync( + path.join(root, 'src/entry.mjs'), + `export const ${name} = 1;\n`, + ); + } + const cocosRoot = path.join(repoRoot, 'plugins/agc-cocos-editor'); + fs.mkdirSync(path.join(cocosRoot, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cocosRoot, 'plugin.json'), + '{"name":"agc-cocos-editor"}\n', + ); + fs.writeFileSync( + path.join(cocosRoot, 'src/entry.mjs'), + 'export const cocos = 1;\n', + ); + } + + if (claudeAgent) { + const declared = declaration.claudeAgent; + const entry = path.join(appRoot, declared.entryRelativePath); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync(entry, 'console.log("sidecar");\n'); + const scope = declared.sdkPackageName.split('/')[0]; + const sdkRoot = path.join( + appRoot, + declared.nodeModulesDirectory, + declared.sdkPackageName, + ); + fs.mkdirSync(path.join(sdkRoot, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(sdkRoot, declared.sdkEntryFileName), + 'export const sdk = 1;\n', + ); + fs.writeFileSync( + path.join(sdkRoot, 'src/runner.mjs'), + 'export const run = 1;\n', + ); + // 上游包里不该随包的内容:跳过规则必须把它们挡在 staging 之外。 + fs.writeFileSync(path.join(sdkRoot, '.env'), 'secret\n'); + fs.writeFileSync(path.join(sdkRoot, 'src/runner.test.mjs'), 'test\n'); + fs.writeFileSync( + path.join(sdkRoot, declared.sdkPackageMetadataFileName), + `${JSON.stringify({ version: declared.version })}\n`, + ); + for (const target of targets) { + const layout = declared.targets.find((item) => item.target === target); + assert.ok(layout, `声明缺少目标 ${target} 的 Claude Agent SDK 布局`); + const runtimeRoot = path.join( + appRoot, + declared.nodeModulesDirectory, + scope, + layout.runtimePackage, + ); + fs.mkdirSync(runtimeRoot, { recursive: true }); + const runtimeFile = path.join(runtimeRoot, layout.runtimeFileName); + fs.writeFileSync(runtimeFile, `runtime ${layout.runtimePackage}\n`); + fs.chmodSync(runtimeFile, 0o755); + fs.writeFileSync( + path.join(runtimeRoot, declared.sdkPackageMetadataFileName), + `${JSON.stringify({ version: declared.version })}\n`, + ); + } + } + + writePrepareArtifacts(repoRoot, declaration); + const cocosDll = path.join( + destinationRoot, + 'target', + WINDOWS_TARGET, + 'debug', + 'deps', + 'cocos_editor_bridge.dll', + ); + fs.mkdirSync(path.dirname(cocosDll), { recursive: true }); + fs.writeFileSync(cocosDll, 'cocos bridge payload\n'); + + const lockfilePath = path.join(root, 'package-lock.json'); + fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2)); + return { + root, + appRoot, + repoRoot, + destinationRoot, + lockfilePath, + recordPath: path.join(root, 'record.json'), + declaration, + cleanup: () => fs.rmSync(root, { recursive: true, force: true }), + }; +} + +function snapshot(directory) { + const entries = []; + const stack = [['', directory]]; + while (stack.length > 0) { + const [prefix, current] = stack.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push([relative, full]); + } else { + const info = fs.statSync(full); + entries.push({ + relative, + size: info.size, + mtimeMs: info.mtimeMs, + mode: info.mode & 0o777, + sha256: sha256File(full), + }); + } + } + } + return entries.sort((left, right) => + left.relative.localeCompare(right.relative), + ); +} + +function prepare(fixture, overrides = {}) { + const runner = overrides.runCommand ?? fakeRunCommand(); + return prepareBundledResources({ + target: WINDOWS_TARGET, + destinationRoot: fixture.destinationRoot, + declarationPath: DECLARATION_PATH, + recordPath: fixture.recordPath, + lockfilePath: fixture.lockfilePath, + repoRoot: fixture.repoRoot, + appRoot: fixture.appRoot, + runCommand: runner, + ...overrides, + }); +} + +/// 替换失败注入:让「staging → 目标目录」的那一次 rename 抛错。 +/// 走 node:fs 的 ESM 活绑定(syncBuiltinESMExports 同步),实现里不得为测试开后门; +/// body 是同步的,注入窗口内不会有别的调用跑进来。 +function withRenameFailure(predicate, body) { + const original = fs.renameSync; + let injected = false; + fs.renameSync = (from, to) => { + if ( + !injected && + predicate(path.resolve(String(from)), path.resolve(String(to))) + ) { + injected = true; + throw Object.assign(new Error('注入的替换失败'), { code: 'EPERM' }); + } + return original.call(fs, from, to); + }; + syncBuiltinESMExports(); + try { + return body(); + } finally { + fs.renameSync = original; + syncBuiltinESMExports(); + } +} + +/// 把「必须失败」的调用收敛成断言:返回错误对象,没抛错即用例失败。 +function expectThrow(body) { + try { + body(); + } catch (error) { + return error; + } + throw new Error('预期抛错但调用成功了'); +} + +test('stages declared codex components with manifest and preserved notice', () => { + const fixture = buildFixture(); + try { + const summaries = prepare(fixture); + assert.match(summaries[0], /codex x86_64-pc-windows-msvc 重新生成/); + + const declaration = fixture.declaration; + const layout = declaration.codex.targets.find( + (entry) => entry.target === WINDOWS_TARGET, + ); + const unit = path.join( + fixture.destinationRoot, + 'resources/codex', + layout.directory, + ); + for (const relative of layout.files) { + assert.ok( + fs.existsSync(path.join(unit, relative)), + `缺少组件 ${relative}`, + ); + } + assert.equal( + fs.readFileSync(path.join(unit, 'NOTICE.md'), 'utf8'), + 'windows codex notice\n', + '受版本控制的第三方声明必须原地保留', + ); + const manifest = JSON.parse( + fs.readFileSync(path.join(unit, 'manifest.json'), 'utf8'), + ); + assert.deepEqual(Object.keys(manifest), [ + 'files', + 'platform', + 'schemaVersion', + 'version', + ]); + assert.equal(manifest.platform, layout.platform); + assert.equal(manifest.schemaVersion, declaration.codex.manifestSchema); + assert.equal( + manifest.version, + `${declaration.codex.cliVersionPrefix}${declaration.codex.version}`, + ); + assert.deepEqual( + Object.keys(manifest.files).sort(), + [...layout.files].sort(), + '清单文件集合必须等于组件白名单', + ); + for (const relative of layout.files) { + assert.equal( + manifest.files[relative], + sha256File(path.join(unit, relative)), + ); + } + } finally { + fixture.cleanup(); + } +}); + +test('second run is a no-op: identical content and timestamps', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + const unit = path.join( + fixture.destinationRoot, + 'resources/codex', + 'win-x64', + ); + const plugins = path.join(fixture.destinationRoot, 'resources/plugins'); + const claudeAgent = path.join( + fixture.destinationRoot, + fixture.declaration.claudeAgent.resourceDirectory, + ); + const before = { + codex: snapshot(unit), + plugins: snapshot(plugins), + claudeAgent: snapshot(claudeAgent), + }; + const summaries = prepare(fixture); + assert.match(summaries[0], /命中缓存/); + assert.match(summaries[1], /命中缓存/); + assert.match(summaries[2], /命中缓存/); + assert.deepEqual( + snapshot(unit), + before.codex, + 'codex 产物内容与时间戳必须不变', + ); + assert.deepEqual( + snapshot(plugins), + before.plugins, + '插件产物内容与时间戳必须不变', + ); + assert.deepEqual( + snapshot(claudeAgent), + before.claudeAgent, + 'Claude Agent SDK sidecar 产物内容与时间戳必须不变', + ); + } finally { + fixture.cleanup(); + } +}); + +test('stages the macOS universal group with both architectures', () => { + const fixture = buildFixture({ + targets: [MAC_TARGET, 'x86_64-apple-darwin'], + }); + try { + const summaries = prepare(fixture, { target: MAC_TARGET }); + assert.match(summaries[0], /mac-native/); + const unit = path.join( + fixture.destinationRoot, + 'resources/codex/mac-native', + ); + for (const directory of ['darwin-arm64', 'darwin-x64']) { + for (const file of ['bin/codex', 'manifest.json', 'NOTICE.md']) { + assert.ok( + fs.existsSync(path.join(unit, directory, file)), + `缺少 ${directory}/${file}`, + ); + } + assert.equal( + fs.readFileSync(path.join(unit, directory, 'NOTICE.md'), 'utf8'), + 'mac codex notice\n', + ); + } + assert.deepEqual(fs.readdirSync(unit).sort(), [ + 'darwin-arm64', + 'darwin-x64', + ]); + } finally { + fixture.cleanup(); + } +}); + +test('copies only whitelisted plugin subdirectories', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + const staged = path.join( + fixture.destinationRoot, + 'resources/plugins/agc-demo-editor', + ); + assert.ok(fs.existsSync(path.join(staged, 'plugin.json'))); + assert.ok(fs.existsSync(path.join(staged, 'src/entry.mjs'))); + assert.ok(fs.existsSync(path.join(staged, 'panels/panel.html'))); + assert.ok( + !fs.existsSync(path.join(staged, 'panels/panel.test.mjs')), + '测试文件不随包', + ); + assert.ok( + !fs.existsSync(path.join(staged, 'target')), + '构建产物目录不随包', + ); + assert.ok(!fs.existsSync(path.join(staged, '.git')), '隐藏目录不随包'); + assert.ok(!fs.existsSync(path.join(staged, '.env')), '隐藏文件不随包'); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when the upstream package metadata drifts from the declaration', () => { + const fixture = buildFixture(); + try { + const declaration = fixture.declaration; + const layout = declaration.codex.targets.find( + (entry) => entry.target === WINDOWS_TARGET, + ); + const metadataFile = path.join( + fixture.appRoot, + `node_modules/@openai/codex-${layout.platform}/vendor/${WINDOWS_TARGET}/codex-package.json`, + ); + const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf8')); + assert.equal(metadata.version, declaration.codex.version); + for (const [key, value] of [ + ['version', '0.0.0'], + ['layoutVersion', 2], + ['entrypoint', 'bin/other.exe'], + ['resourcesDir', '../private'], + ]) { + fs.writeFileSync( + metadataFile, + `${JSON.stringify({ ...metadata, [key]: value }, null, 2)}\n`, + ); + assert.throws( + () => prepare(fixture), + /上游包元数据与声明不一致/, + `${key} 漂移必须被拒绝`, + ); + } + assert.ok( + !fs.existsSync( + path.join( + fixture.destinationRoot, + 'resources/codex/win-x64/manifest.json', + ), + ), + '拒绝时不得留下产物', + ); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when the upstream package is missing', () => { + const fixture = buildFixture(); + try { + fs.rmSync(path.join(fixture.appRoot, 'node_modules'), { + recursive: true, + force: true, + }); + assert.throws(() => prepare(fixture), /npm ci/); + assert.ok( + !fs.existsSync( + path.join( + fixture.destinationRoot, + 'resources/codex/win-x64/manifest.json', + ), + ), + '失败时不得留下半成品清单', + ); + assert.ok( + !fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')), + ); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed for unsupported targets', () => { + const fixture = buildFixture(); + try { + assert.throws( + () => prepare(fixture, { target: 'x86_64-unknown-linux-gnu' }), + /声明不含目标/, + ); + assert.throws(() => resolveHostTarget('linux', 'x64'), /不支持的目标平台/); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when the destination is owned by something else', () => { + const fixture = buildFixture(); + try { + const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64'); + fs.writeFileSync(path.join(unit, 'foreign.bin'), 'foreign\n'); + assert.throws(() => prepare(fixture), /被非本工具内容占用/); + + const plugins = path.join(fixture.destinationRoot, 'resources/plugins'); + fs.rmSync(path.join(unit, 'foreign.bin'), { force: true }); + fs.mkdirSync(path.join(plugins, 'someone-elses-plugin'), { + recursive: true, + }); + assert.throws(() => prepare(fixture), /插件随包目录被非本工具内容占用/); + } finally { + fixture.cleanup(); + } +}); + +test('stages the Claude Agent SDK sidecar with the platform runtime', () => { + const fixture = buildFixture(); + try { + const summaries = prepare(fixture); + const declared = fixture.declaration.claudeAgent; + assert.match(summaries[2], /claude-agent x86_64-pc-windows-msvc 重新生成/); + const unit = path.join(fixture.destinationRoot, declared.resourceDirectory); + const layout = declared.targets.find( + (entry) => entry.target === WINDOWS_TARGET, + ); + const scope = declared.sdkPackageName.split('/')[0]; + const stagedSdk = path.join( + unit, + declared.nodeModulesDirectory, + declared.sdkPackageName, + ); + for (const file of [ + path.join(unit, declared.entryFileName), + path.join(stagedSdk, declared.sdkEntryFileName), + path.join(stagedSdk, 'src/runner.mjs'), + path.join( + unit, + declared.nodeModulesDirectory, + scope, + layout.runtimePackage, + layout.runtimeFileName, + ), + ]) { + assert.ok(fs.existsSync(file), `缺少 ${file}`); + } + // 跳过规则:上游包里的私密文件与测试文件不得随包。 + assert.ok(!fs.existsSync(path.join(stagedSdk, '.env'))); + assert.ok(!fs.existsSync(path.join(stagedSdk, 'src/runner.test.mjs'))); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when the Claude Agent SDK upstream package is missing', () => { + const fixture = buildFixture(); + try { + fs.rmSync(path.join(fixture.appRoot, 'node_modules/@anthropic-ai'), { + recursive: true, + force: true, + }); + assert.throws(() => prepare(fixture), /Claude Agent SDK 上游包缺失/); + assert.ok( + !fs.existsSync( + path.join( + fixture.destinationRoot, + fixture.declaration.claudeAgent.resourceDirectory, + ), + ), + '失败关闭时不得留下半成品', + ); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when the upstream Claude Agent SDK version drifts', () => { + const fixture = buildFixture(); + try { + const declared = fixture.declaration.claudeAgent; + const metadataFile = path.join( + fixture.appRoot, + declared.nodeModulesDirectory, + declared.sdkPackageName, + declared.sdkPackageMetadataFileName, + ); + fs.writeFileSync(metadataFile, `${JSON.stringify({ version: '0.0.0' })}\n`); + assert.throws(() => prepare(fixture), /版本与声明不一致/); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when the Claude Agent SDK destination is owned by something else', () => { + const fixture = buildFixture(); + try { + const unit = path.join( + fixture.destinationRoot, + fixture.declaration.claudeAgent.resourceDirectory, + ); + fs.mkdirSync(unit, { recursive: true }); + fs.writeFileSync(path.join(unit, 'foreign.bin'), 'foreign\n'); + assert.throws(() => prepare(fixture), /sidecar 随包目录被非本工具内容占用/); + } finally { + fixture.cleanup(); + } +}); + +test('dry run writes nothing', () => { + const fixture = buildFixture(); + try { + const summaries = prepare(fixture, { + dryRun: true, + features: new Set([ + 'unity-editor-execute', + 'godot-editor-execute', + 'cocos-editor-injection', + ]), + }); + assert.match(summaries[0], /需要重新生成(dry-run 未写入)/); + const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64'); + assert.deepEqual( + fs.readdirSync(unit), + ['NOTICE.md'], + 'dry-run 不得写入任何组件或清单', + ); + assert.ok( + !fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')), + ); + assert.ok( + !fs.existsSync( + path.join( + fixture.destinationRoot, + fixture.declaration.claudeAgent.resourceDirectory, + ), + ), + ); + assert.ok(!fs.existsSync(fixture.recordPath)); + } finally { + fixture.cleanup(); + } +}); + +test('declaration drives source lookup and staging units', () => { + const declaration = readDeclaration(DECLARATION_PATH); + const windows = stagingUnit(declaration, WINDOWS_TARGET); + assert.equal(windows.directory, 'win-x64'); + assert.deepEqual( + windows.targets.map((member) => member.target), + [WINDOWS_TARGET], + ); + const mac = stagingUnit(declaration, MAC_TARGET); + assert.equal(mac.directory, 'mac-native'); + assert.deepEqual( + mac.targets.map((member) => member.target), + ['aarch64-apple-darwin', 'x86_64-apple-darwin'], + ); + + const fixture = buildFixture(); + try { + const source = findCodexSource(declaration, WINDOWS_TARGET, { + app: fixture.appRoot, + repo: fixture.repoRoot, + }); + assert.match( + source, + /codex-win32-x64[\\/]vendor[\\/]x86_64-pc-windows-msvc$/u, + ); + assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 4); + } finally { + fixture.cleanup(); + } +}); + +test('runs declared prepare steps once and copies their artifacts into the staged tree', () => { + const fixture = buildFixture(); + try { + const created = []; + const summaries = prepare(fixture, { + runCommand: fakeRunCommand({ created }), + features: new Set([ + 'unity-editor-execute', + 'godot-editor-execute', + 'cocos-editor-injection', + ]), + }); + assert.match(summaries[0], /命中缓存|重新生成/); + assert.ok( + created.some( + (entry) => + entry.startsWith('powershell.exe') && entry.includes('build.ps1'), + ), + '必须执行声明的 powershell 准备步骤', + ); + assert.ok( + created.some( + (entry) => + entry.startsWith('cargo build') && entry.includes('--target'), + ), + '必须执行声明的 cargo 准备步骤', + ); + const stagedPayload = path.join( + fixture.destinationRoot, + 'resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll', + ); + assert.ok(fs.existsSync(stagedPayload), 'native payload 必须进随包目录'); + assert.ok( + fs.existsSync( + path.join( + fixture.repoRoot, + 'plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll', + ), + ), + 'native payload 必须写回插件工作区(唯一真源)', + ); + } finally { + fixture.cleanup(); + } +}); + +test('skips prepare steps whose fingerprint is unchanged', () => { + const fixture = buildFixture(); + try { + const first = []; + prepare(fixture, { runCommand: fakeRunCommand({ created: first }) }); + assert.ok(first.length > 0, '首次必须执行准备步骤'); + const second = []; + prepare(fixture, { runCommand: fakeRunCommand({ created: second }) }); + const unityRuns = second.filter( + (entry) => + entry.startsWith('powershell.exe') && + entry.includes('agc-unity-editor'), + ); + assert.deepEqual( + unityRuns, + [], + '指纹一致时不应再跑声明了指纹的 Unity 准备步骤', + ); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when a prepare step does not produce its declared outputs', () => { + const fixture = buildFixture(); + try { + fs.rmSync(path.join(fixture.repoRoot, 'plugins/agc-unity-editor'), { + recursive: true, + force: true, + }); + assert.throws( + () => prepare(fixture, { runCommand: fakeRunCommand() }), + /未产出必需文件/, + ); + } finally { + fixture.cleanup(); + } +}); + +test('delivers editor branch artifacts declared as prepared or library staging', () => { + const fixture = buildFixture(); + try { + const features = new Set([ + 'unity-editor-execute', + 'godot-editor-execute', + 'cocos-editor-injection', + ]); + prepare(fixture, { features }); + const staged = (relative) => + path.join(fixture.destinationRoot, 'resources/plugins', relative); + + // Unity:prepared 子目录整体复制 + assert.ok( + fs.existsSync( + staged('agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe'), + ), + 'Unity helper 必须随包', + ); + // Godot:libraryStaging 声明文件逐个复制 + assert.ok( + fs.existsSync( + staged( + 'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll', + ), + ), + 'Godot 扩展必须随包', + ); + assert.ok( + fs.existsSync( + staged('agc-godot-editor/native/gdextension/vendor/provenance.json'), + ), + 'Godot 随包清单文件必须随包', + ); + } finally { + fixture.cleanup(); + } +}); + +/// 全量 Windows 编辑器 feature:prepared / libraryStaging / nativePayload 三条交付路径全开。 +const ALL_WINDOWS_FEATURES = new Set([ + 'unity-editor-execute', + 'godot-editor-execute', + 'cocos-editor-injection', +]); + +function pluginStaged(fixture, relative) { + return path.join( + fixture.destinationRoot, + fixture.declaration.plugins.destinationDirectory, + relative, + ); +} + +test('keeps the previous codex resources when the replacement fails', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + const declaration = fixture.declaration; + const layout = declaration.codex.targets.find( + (entry) => entry.target === WINDOWS_TARGET, + ); + const unit = path.join( + fixture.destinationRoot, + declaration.codex.resourceDirectory, + layout.directory, + ); + const before = snapshot(unit); + const component = layout.files.find((relative) => + relative.includes('code-mode-host'), + ); + const vendor = path.join( + fixture.appRoot, + `node_modules/@openai/codex-${layout.platform}/vendor/${WINDOWS_TARGET}`, + ); + // 上游组件变了:这一次必然走「staging → 目标」的替换,注入的失败正好落在替换上。 + fs.writeFileSync(path.join(vendor, component), 'changed component\n'); + // 锁定包未变化时 Codex 走缓存;移除记录强制重新读取上游内容。 + fs.rmSync(fixture.recordPath); + + const error = expectThrow(() => + withRenameFailure( + (from, to) => to === path.resolve(unit), + () => prepare(fixture), + ), + ); + assert.match(error.message, /替换 .*win-x64.* 失败/u); + assert.match(error.message, /旧资源已恢复/u); + assert.deepEqual( + snapshot(unit), + before, + '替换失败必须把旧资源原样恢复:内容、尺寸、时间戳与可执行位都不许变', + ); + assert.equal( + fs.readFileSync( + path.join(unit, declaration.codex.noticeFileName), + 'utf8', + ), + 'windows codex notice\n', + '受版本控制的第三方声明必须还在原位', + ); + const stagingPath = /staging 保留在 ([^;]+);/u.exec(error.message)?.[1]; + assert.ok(stagingPath, `报错必须给出 staging 位置:${error.message}`); + // 目标目录旁边只允许剩「旧资源」和「留给人工检查的新 staging」;backup 必须已经归位。 + const siblings = fs.readdirSync(path.dirname(unit)); + assert.ok(siblings.includes(path.basename(unit))); + assert.ok(siblings.includes(path.basename(stagingPath))); + assert.ok( + siblings.every((entry) => !entry.includes('-backup-')), + '恢复成功后不得留下 backup 目录', + ); + assert.equal( + fs.readFileSync(path.join(stagingPath, component), 'utf8'), + 'changed component\n', + '没有落盘的新产物必须留在 staging 里供人工检查', + ); + + // 注入消失后重试必须成功:旧资源完整 → 替换重新做一遍 → 落盘的是上游新内容。 + const summaries = prepare(fixture); + assert.match(summaries[0], /重新生成/u); + assert.equal( + fs.readFileSync(path.join(unit, component), 'utf8'), + 'changed component\n', + ); + } finally { + fixture.cleanup(); + } +}); + +test('keeps the previous plugin resources when the replacement fails', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + const destination = path.join( + fixture.destinationRoot, + fixture.declaration.plugins.destinationDirectory, + ); + // 未声明的额外文件让缓存判定必然漂移:这次一定会走完整替换。 + fs.writeFileSync( + path.join(destination, 'agc-demo-editor/src/rogue.mjs'), + 'rogue\n', + ); + const before = snapshot(destination); + + const error = expectThrow(() => + withRenameFailure( + (from, to) => to === path.resolve(destination), + () => prepare(fixture), + ), + ); + assert.match(error.message, /替换 .*plugins.* 失败/u); + assert.match(error.message, /旧资源已恢复/u); + assert.deepEqual( + snapshot(destination), + before, + '替换失败时旧插件资源(含尚未清理的额外文件)必须逐字节保留', + ); + + prepare(fixture); + assert.ok( + !fs.existsSync(path.join(destination, 'agc-demo-editor/src/rogue.mjs')), + '注入消失后重试必须成功并清掉额外文件', + ); + assert.ok( + fs.existsSync( + path.join(destination, 'agc-demo-editor/panels/panel.html'), + ), + '重试后声明的随包内容必须齐全', + ); + } finally { + fixture.cleanup(); + } +}); + +test('clears undeclared and hidden entries left in the staged plugin tree', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + // 三种残留:额外目录、隐藏目录、声明的源码目录里的隐藏文件。 + for (const relative of [ + 'agc-demo-editor/extra/rogue.txt', + 'agc-demo-editor/.cache/tmp.bin', + 'agc-demo-editor/src/.env', + ]) { + const file = pluginStaged(fixture, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, 'residue\n'); + } + + const summaries = prepare(fixture); + assert.match(summaries[1], /plugins 重新生成/u); + for (const relative of [ + 'agc-demo-editor/extra', + 'agc-demo-editor/.cache', + 'agc-demo-editor/src/.env', + ]) { + assert.ok( + !fs.existsSync(pluginStaged(fixture, relative)), + `未声明的残留 ${relative} 必须被清掉`, + ); + } + for (const relative of [ + 'agc-demo-editor/plugin.json', + 'agc-demo-editor/src/entry.mjs', + 'agc-demo-editor/panels/panel.html', + ]) { + assert.ok( + fs.existsSync(pluginStaged(fixture, relative)), + `声明的随包内容 ${relative} 必须还在`, + ); + } + assert.ok( + !fs.existsSync( + pluginStaged(fixture, 'agc-demo-editor/panels/panel.test.mjs'), + ), + '跳过规则在重建后依然生效', + ); + } finally { + fixture.cleanup(); + } +}); + +test('removes staged plugin files whose source has been deleted', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + assert.ok( + fs.existsSync(pluginStaged(fixture, 'agc-demo-editor/panels/panel.html')), + '前置条件:源码里的文件已经随包', + ); + fs.rmSync( + path.join(fixture.repoRoot, 'plugins/agc-demo-editor/panels/panel.html'), + ); + + const summaries = prepare(fixture); + assert.match(summaries[1], /plugins 重新生成/u); + assert.ok( + !fs.existsSync( + pluginStaged(fixture, 'agc-demo-editor/panels/panel.html'), + ), + '源码里已删除的文件不得留在随包目录', + ); + assert.ok( + fs.existsSync(pluginStaged(fixture, 'agc-demo-editor/src/entry.mjs')), + '同一插件的其他声明内容必须还在', + ); + // 重建后的目录必须自洽:紧接着一次准备应命中缓存而不是反复重建。 + assert.match(prepare(fixture)[1], /命中缓存/u); + } finally { + fixture.cleanup(); + } +}); + +test('clears staged prepared payloads when their feature is disabled', () => { + const fixture = buildFixture(); + try { + prepare(fixture, { features: ALL_WINDOWS_FEATURES }); + for (const relative of [ + 'agc-cocos-editor/native/payload/cocos-editor-bridge.dll', + 'agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe', + 'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll', + 'agc-godot-editor/native/gdextension/vendor/provenance.json', + ]) { + assert.ok( + fs.existsSync(pluginStaged(fixture, relative)), + `前置条件:feature 全开时 ${relative} 必须随包`, + ); + } + + prepare(fixture, { features: new Set() }); + for (const relative of [ + 'agc-cocos-editor/native/payload', + 'agc-unity-editor/dotnet', + 'agc-godot-editor/native', + ]) { + assert.ok( + !fs.existsSync(pluginStaged(fixture, relative)), + `feature 关闭后 ${relative} 不得作为残留继续随包`, + ); + } + for (const plugin of [ + 'agc-cocos-editor', + 'agc-unity-editor', + 'agc-godot-editor', + ]) { + assert.ok( + fs.existsSync(pluginStaged(fixture, `${plugin}/plugin.json`)), + `feature 关闭不得动到 ${plugin} 的声明内容`, + ); + assert.ok( + fs.existsSync(pluginStaged(fixture, `${plugin}/src/entry.mjs`)), + `feature 关闭不得动到 ${plugin} 的源码内容`, + ); + } + } finally { + fixture.cleanup(); + } +}); + +test('treats declared prepared and library-staging artifacts as owned on a second run', () => { + const fixture = buildFixture(); + try { + prepare(fixture, { features: ALL_WINDOWS_FEATURES }); + const destination = path.join( + fixture.destinationRoot, + fixture.declaration.plugins.destinationDirectory, + ); + const before = snapshot(destination); + + const summaries = prepare(fixture, { features: ALL_WINDOWS_FEATURES }); + assert.match( + summaries[1], + /plugins 命中缓存(未写入/u, + 'prepared 子目录与 libraryStaging 产物属于本工具,不得被误判成外来内容而反复重建', + ); + assert.deepEqual( + snapshot(destination), + before, + '命中缓存时一个字节、一个时间戳都不许动', + ); + for (const relative of [ + 'agc-cocos-editor/native/payload/cocos-editor-bridge.dll', + 'agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe', + 'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll', + 'agc-godot-editor/native/gdextension/vendor/provenance.json', + ]) { + assert.ok( + fs.existsSync(pluginStaged(fixture, relative)), + `已声明产物 ${relative} 不得因为缓存判定被删掉`, + ); + } + } finally { + fixture.cleanup(); + } +}); diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index e619c7123..3facee7e6 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -1,8 +1,10 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs'; import { - defaultEditorFeatures, + readCargoTarget, + resolveEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; import { @@ -10,6 +12,10 @@ import { resolveAgcDevEndpoint, withAgcDevEndpointEnv, } from './dev-port.mjs'; +import { + prepareBundledResources, + supportedHostTarget, +} from './prepare-bundled-resources.mjs'; import { isAiGameCreatorServer, preflightExistingVite, @@ -51,22 +57,51 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { } // 开发和发行构建使用同一平台编辑器 feature 集合。 -// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。 -function readDevCargoFeatures(env = process.env) { - const override = env.AGC_DEV_CARGO_FEATURES; - if (override !== undefined) { - return override - .split(',') - .map((value) => value.trim()) - .filter(Boolean); - } - return defaultEditorFeatures(process.platform); +// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭; +// 命令行显式 `--features` / `-f` 优先于环境变量(cargo 实际收到的是命令行参数)。 +// 入参是「最终交给 Tauri 的参数」:默认平台由其中的显式 `--target` 决定,未指定才是宿主平台。 +function readDevCargoFeatures(argv = []) { + return resolveEditorFeatures({ + argv, + target: readCargoTarget(argv) ?? process.platform, + }); } -function withDevCargoFeatures(argv, features = readDevCargoFeatures()) { +function withDevCargoFeatures(argv, features = readDevCargoFeatures(argv)) { return withDefaultCargoFeatures(argv, features); } +/// 随包资源必须在 Tauri 之前生成:构建脚本只做只读校验,不再生成资源。 +/// 命中缓存的重复调用不写任何文件,因此每次 dev 启动都会先跑一次。 +/// `argv` 是「最终交给 Tauri 的参数」;staging 目标与本次 cargo 构建目标同源: +/// 显式 `--target` 优先,未指定时回落宿主目标;宿主平台不受声明覆盖(如 Linux)时保持跳过, +/// 不为其新增随包资源准备。`features` 省略时按同一份参数解析,不允许「cargo 一套、staging 另一套」。 +function prepareBundledResourcesBeforeTauri( + features, + argv = [], + { + prepare = prepareBundledResources, + log = console.log, + hostTarget = supportedHostTarget(), + } = {}, +) { + const target = readCargoTarget(argv) ?? hostTarget; + if (!target) { + log( + '[ai-game-creator-shell] 当前平台不受随包资源声明覆盖,跳过随包资源准备', + ); + return; + } + const summaries = prepare({ + target, + features: new Set(features ?? readDevCargoFeatures(argv)), + log: (line) => log(`[ai-game-creator-shell] ${line}`), + }); + for (const summary of summaries) { + log(`[ai-game-creator-shell] ${summary}`); + } +} + function spawnTauriCli(argv, { env = process.env } = {}) { return spawnChild(process.execPath, [tauriCliPath, ...argv], { cwd: appRoot, @@ -75,6 +110,17 @@ function spawnTauriCli(argv, { env = process.env } = {}) { }); } +/// Tauri dev 的 Cargo 直接继承启动器环境,用户级 / 仓库级 Cargo 配置里的 +/// `rustc-wrapper`(本地常见为 sccache)会在这里生效。本地 sccache daemon 状态 +/// 一旦损坏,`cargo` 的首次 rustc 探测就会失败并阻断整个 AGC 启动;因此这里复用 +/// `npm run dev` 的本地 Rust 环境规则,由脚本而不是本机 Cargo 配置决定 wrapper。 +function buildTauriDevProcessEnv(endpoint, env = process.env) { + return buildLocalRustProcessEnv({ + ...withAgcDevEndpointEnv(endpoint, env), + [AGC_DESIGN_DEBUG_ENV]: designDebugEnabled, + }); +} + async function runTauriDev( argv = process.argv.slice(2), { @@ -84,6 +130,7 @@ async function runTauriDev( spawnCli = spawnTauriCli, waitForCli = waitForChildTermination, terminateTree = terminateChildTree, + prepareResources = prepareBundledResourcesBeforeTauri, } = {}, ) { const endpoint = await resolveDevEndpoint(); @@ -119,6 +166,12 @@ async function runTauriDev( } try { + // 目标与 feature 都从「最终交给 Tauri 的参数」解析:启动器自己的 `--` 之后是应用参数, + // 不能参与解析;注入默认 feature 也不改变这份解析结果。 + const devTauriArguments = buildTauriArguments(argv, endpoint.url); + const devFeatures = readDevCargoFeatures(devTauriArguments); + prepareResources(devFeatures, devTauriArguments); + // Windows 下 Vite 会监听资源目录;必须在启动前完成目录替换,避免 rename 被占用。 const preparation = prepareFrontend(endpoint, { signal: preparationAbort.signal, onChild(frontend) { @@ -131,14 +184,11 @@ async function runTauriDev( ]); if (!prepared || shutdownSignal) return 1; const tauriArguments = buildTauriArguments( - withDevCargoFeatures(argv), + withDevCargoFeatures(argv, devFeatures), endpoint.url, ); child = spawnCli(tauriArguments, { - env: { - ...withAgcDevEndpointEnv(endpoint), - [AGC_DESIGN_DEBUG_ENV]: designDebugEnabled, - }, + env: buildTauriDevProcessEnv(endpoint), }); const childResult = waitForCli(child); const outcome = await Promise.race([ @@ -219,13 +269,15 @@ async function prepareFrontendDev(endpoint, { onChild, signal }) { function isDirectModuleExecution() { return Boolean( process.argv[1] && - resolve(process.argv[1]) === fileURLToPath(import.meta.url), + resolve(process.argv[1]) === fileURLToPath(import.meta.url), ); } export { buildTauriArguments, + buildTauriDevProcessEnv, isDirectModuleExecution, + prepareBundledResourcesBeforeTauri, runTauriDev, spawnTauriCli, withDevCargoFeatures, diff --git a/apps/ai-game-creator-shell/src-tauri/.taurignore b/apps/ai-game-creator-shell/src-tauri/.taurignore deleted file mode 100644 index 8ec126556..000000000 --- a/apps/ai-game-creator-shell/src-tauri/.taurignore +++ /dev/null @@ -1,4 +0,0 @@ -# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。 -# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发 -# “构建 -> 监听 -> 再构建”的自触发循环。 -resources/plugins/ diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 9238cb34c..d84e818a7 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -1,7 +1,8 @@ +// 构建脚本只用布局里的目录与校验入口(写入分支已移交准备步骤), +// 其余字段与常量供运行期使用,因此这里不报构建上下文里的 dead_code。 +#[allow(dead_code)] #[path = "build_support/codex_bundle.rs"] mod codex_bundle; -#[path = "build_support/codex_package_metadata.rs"] -mod codex_package_metadata; #[path = "build_support/frontend_dist_guard.rs"] mod frontend_dist_guard; #[path = "build_support/godot_bundle.rs"] @@ -9,287 +10,12 @@ mod godot_bundle; #[path = "build_support/runtime_prompt_bundle.rs"] mod runtime_prompt_bundle; -use sha2::{Digest, Sha256}; use std::collections::BTreeSet; use std::env; use std::fs; use std::path::PathBuf; -use std::io::{BufReader, Read}; - -fn sha256_file(path: &std::path::Path) -> Result { - let file = fs::File::open(path)?; - let mut reader = BufReader::new(file); - let mut hasher = Sha256::new(); - let mut buffer = [0_u8; 64 * 1024]; - loop { - let read = reader.read(&mut buffer)?; - if read == 0 { - break; - } - hasher.update(&buffer[..read]); - } - Ok(format!("{:x}", hasher.finalize())) -} - -fn claude_agent_platform(target: &str) -> Option<&'static str> { - match target { - "x86_64-pc-windows-msvc" => Some("win32-x64"), - "aarch64-pc-windows-msvc" => Some("win32-arm64"), - "x86_64-apple-darwin" => Some("darwin-x64"), - "aarch64-apple-darwin" => Some("darwin-arm64"), - "x86_64-unknown-linux-gnu" => Some("linux-x64"), - "aarch64-unknown-linux-gnu" => Some("linux-arm64"), - "x86_64-unknown-linux-musl" => Some("linux-x64-musl"), - "aarch64-unknown-linux-musl" => Some("linux-arm64-musl"), - _ => None, - } -} - -fn stage_bundled_claude_agent(manifest_dir: &std::path::Path) { - let target = env::var("TARGET").expect("Cargo TARGET"); - let Some(platform) = claude_agent_platform(&target) else { - println!("cargo:warning=Claude Agent SDK sidecar 暂不支持目标:{target}"); - return; - }; - let app_root = manifest_dir.parent().expect("AGC Tauri manifest parent"); - let repo_root = app_root - .parent() - .and_then(|apps_dir| apps_dir.parent()) - .expect("AGC 应用必须位于仓库 apps 目录下"); - let roots = [app_root, repo_root]; - let sdk_source = roots - .iter() - .map(|root| root.join("node_modules/@anthropic-ai/claude-agent-sdk")) - .find(|path| path.join("sdk.mjs").is_file()) - .unwrap_or_else(|| panic!("Claude Agent SDK 缺失;请先执行 npm ci")); - let binary_package = format!("claude-agent-sdk-{platform}"); - let binary_source = roots - .iter() - .map(|root| { - root.join("node_modules/@anthropic-ai") - .join(&binary_package) - }) - .find(|path| path.is_dir()) - .unwrap_or_else(|| panic!("Claude Agent SDK 原生运行时缺失:{binary_package}")); - let entry_source = app_root.join("agent-sidecar/src/index.mjs"); - assert!(entry_source.is_file(), "Claude Agent SDK sidecar 入口缺失"); - let target_root = manifest_dir.join("resources/claude-agent"); - if target_root.exists() { - fs::remove_dir_all(&target_root).expect("清理 Claude Agent SDK staging 失败"); - } - fs::create_dir_all(target_root.join("node_modules/@anthropic-ai")) - .expect("创建 Claude Agent SDK staging 目录失败"); - stage_plugin_file(&entry_source, &target_root.join("index.mjs")); - copy_plugin_tree( - &sdk_source, - &target_root.join("node_modules/@anthropic-ai/claude-agent-sdk"), - ); - copy_plugin_tree( - &binary_source, - &target_root - .join("node_modules/@anthropic-ai") - .join(&binary_package), - ); - let binary_name = if target.contains("windows") { - "claude.exe" - } else { - "claude" - }; - let staged_binary = target_root - .join("node_modules/@anthropic-ai") - .join(&binary_package) - .join(binary_name); - if let Ok(metadata) = fs::metadata(binary_source.join(binary_name)) { - fs::set_permissions(&staged_binary, metadata.permissions()) - .expect("保留 Claude Agent SDK 原生运行时权限失败"); - } - println!("cargo:rerun-if-changed={}", entry_source.display()); - println!("cargo:rerun-if-changed={}", sdk_source.display()); - println!("cargo:rerun-if-changed={}", binary_source.display()); -} - -fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { - let target = env::var("TARGET").expect("Cargo TARGET"); - println!("cargo:rustc-env=AGC_BUILD_TARGET={target}"); - if target.contains("apple-darwin") { - // Tauri 的 universal 两次 Cargo 编译共用 resource staging, - // 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。 - let staging = manifest_dir.join("resources/codex/mac-native"); - if staging.exists() { - fs::remove_dir_all(&staging).expect("清理 macOS Codex staging 失败"); - } - for target in ["aarch64-apple-darwin", "x86_64-apple-darwin"] { - stage_codex_target(manifest_dir, target); - } - } else { - stage_codex_target(manifest_dir, &target); - } -} - -fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { - let Some(layout) = codex_bundle::for_target(target) else { - assert!( - !target.contains("windows") && !target.contains("apple-darwin"), - "不支持的 Codex 随包目标:{target}" - ); - return; - }; - { - let app_root = manifest_dir - .parent() - .expect("AI 游戏创作 Tauri manifest 必须位于应用目录下"); - let repo_root = app_root - .parent() - .and_then(|apps_dir| apps_dir.parent()) - .expect("AI 游戏创作应用必须位于仓库 apps 目录下"); - let package = format!("codex-{}", layout.platform); - let source_candidates = [app_root, repo_root] - .into_iter() - .flat_map(|root| { - [ - root.join(format!("node_modules/@openai/{package}/vendor/{target}")), - root.join(format!( - "node_modules/@openai/codex/node_modules/@openai/{package}/vendor/{target}" - )), - ] - }) - .collect::>(); - let source = source_candidates - .iter() - .find(|path| { - layout - .files - .iter() - .all(|relative| path.join(relative).is_file()) - }) - .cloned() - .unwrap_or_else(|| { - panic!( - "内置 Codex CLI 缺失;请先在仓库根目录执行 npm ci(已检查:{})", - source_candidates - .iter() - .map(|path| path.display().to_string()) - .collect::>() - .join(";") - ) - }); - let metadata: serde_json::Value = serde_json::from_slice( - &fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"), - ) - .expect("Codex 原生包元数据无效"); - codex_package_metadata::validate_package_metadata(&metadata, target, layout) - .unwrap_or_else(|error| panic!("{error}")); - let target_dir = manifest_dir.join("resources/codex").join(layout.directory); - let notice = target_dir.join("NOTICE.md"); - if target.contains("apple-darwin") { - let source_notice = - manifest_dir.join("resources/codex/【声明】Mac内置Codex组件-2026-09-18.md"); - stage_plugin_file(&source_notice, ¬ice); - println!("cargo:rerun-if-changed={}", source_notice.display()); - } - if !notice.is_file() { - panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display()); - } - fs::create_dir_all(&target_dir).expect("创建内置 Codex CLI 资源目录失败"); - // 这份目录是随包资源(Windows:`resources/codex/win-x64/**` → `coding-agent/win-x64/**`), - // 只能包含本轮布局声明的组件。上一版布局留下的旧二进制(例如包根目录那份 0.147.0 - // `codex.exe`)会长期留在原地:既误导本地核对与夹具,也让「随包内容」与清单不一致。 - prune_stale_codex_components(&target_dir, layout.files); - let mut file_hashes = serde_json::Map::new(); - for relative in layout.files { - let source_path = source.join(relative); - let target_path = target_dir.join(relative); - if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent).expect("创建内置 Codex CLI 资源子目录失败"); - } - let source_sha256 = sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败"); - let target_matches_source = target_path.is_file() - && sha256_file(&target_path) - .map(|target_sha256| target_sha256 == source_sha256) - .unwrap_or(false); - let source_permissions = fs::metadata(&source_path) - .expect("读取组件权限失败") - .permissions(); - if !target_matches_source { - fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败"); - fs::set_permissions(&target_path, source_permissions.clone()) - .expect("保留内置 Codex CLI 组件权限失败"); - } else if fs::metadata(&target_path) - .expect("读取内置 Codex CLI 资源失败") - .permissions() - != source_permissions - { - // 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。 - // 权限已一致时不再写元数据:Windows 上这次写入会更新 change time, - // 让 tauri dev 的文件监听把每次构建都当成 staging 变更而无限重建。 - fs::set_permissions(&target_path, source_permissions) - .expect("保留内置 Codex CLI 组件权限失败"); - } - file_hashes.insert( - relative.to_string(), - serde_json::Value::String(source_sha256), - ); - } - let manifest = serde_json::json!({ - "schemaVersion": codex_bundle::SCHEMA, - "platform": layout.platform, - "version": codex_bundle::CLI_VERSION, - "files": file_hashes, - }); - let manifest_path = target_dir.join("manifest.json"); - let manifest_payload = format!( - "{}\n", - serde_json::to_string_pretty(&manifest).expect("序列化内置 Codex CLI 清单失败") - ); - if fs::read_to_string(&manifest_path) - .map(|current| current != manifest_payload) - .unwrap_or(true) - { - fs::write(&manifest_path, manifest_payload).expect("写入内置 Codex CLI 清单失败"); - } - for relative in layout.files { - println!("cargo:rerun-if-changed={}", source.join(relative).display()); - } - println!("cargo:rerun-if-changed={}", notice.display()); - } -} - -/// 删除 `target_dir` 下不在本轮布局内的残留文件;空目录一并收掉。 -fn prune_stale_codex_components(target_dir: &std::path::Path, files: &[&str]) { - const ALWAYS_KEEP: &[&str] = &["manifest.json", "NOTICE.md"]; - fn walk(root: &std::path::Path, directory: &std::path::Path, files: &[&str], keep: &[&str]) { - let Ok(entries) = fs::read_dir(directory) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - let Ok(kind) = entry.file_type() else { - continue; - }; - if kind.is_dir() { - walk(root, &path, files, keep); - if fs::read_dir(&path) - .map(|mut remaining| remaining.next().is_none()) - .unwrap_or(false) - { - let _ = fs::remove_dir(&path); - } - continue; - } - let Ok(relative) = path.strip_prefix(root) else { - continue; - }; - let relative = relative.to_string_lossy().replace('\\', "/"); - if files.contains(&relative.as_str()) || keep.contains(&relative.as_str()) { - continue; - } - eprintln!("cargo:warning=清理内置 Codex 组件残留:{relative}"); - let _ = fs::remove_file(&path); - } - } - walk(target_dir, target_dir, files, ALWAYS_KEEP); -} +use codex_bundle::package_layout; fn seed_task_group_id( group: &shared_contracts::game_creation_app::GameCreationAppAgentGroup, @@ -334,17 +60,128 @@ fn validate_seed_task_catalog(compiled: &runtime_prompt_bundle::CompiledPromptBu } } +/// 只读校验:确认已经落盘的随包产物与声明一致。本函数不写任何文件。 +fn validate_staged_resources(manifest_dir: &std::path::Path) { + let target = env::var("TARGET").expect("Cargo TARGET"); + for staged_target in package_layout::staged_targets(&target) { + let Some(layout) = codex_bundle::for_target(staged_target) else { + continue; + }; + let target_dir = manifest_dir + .join(package_layout::codex().resource_directory) + .join(layout.directory); + package_layout::validate_staged_codex_bundle(&target_dir, staged_target).unwrap_or_else( + |error| panic!("内置 Codex CLI 随包资源校验失败({staged_target}):{error}"), + ); + } + validate_staged_claude_agent(manifest_dir, &target); + validate_staged_plugin_workspace(manifest_dir, &target); + validate_prepared_payloads(manifest_dir, &target); +} + +/// 只读校验 Claude Agent SDK sidecar:入口、SDK 与平台原生运行时必须在位。 +/// 未声明该目标时不随包 sidecar,直接放行(Linux 等平台不参与客户端打包)。 +fn validate_staged_claude_agent(manifest_dir: &std::path::Path, target: &str) { + if package_layout::claude_agent_target(target).is_none() { + return; + } + let app_root = manifest_dir + .parent() + .expect("AI 游戏创作 Tauri manifest 必须位于应用目录下"); + let target_dir = manifest_dir.join(package_layout::claude_agent().resource_directory); + package_layout::validate_staged_claude_agent(&target_dir, app_root, target).unwrap_or_else( + |error| panic!("Claude Agent SDK sidecar 随包资源校验失败({target}):{error}"), + ); +} + +/// 只读校验插件随包工作区:声明的源码派生内容必须与仓库源码逐文件一致,整树无符号链接。 +/// 已准备产物与随包库在本机无法重建,构建期至少要确认它们已经就位。 +fn validate_prepared_payloads(manifest_dir: &std::path::Path, target: &str) { + if !package_layout::plugin_staging_applies(target) { + return; + } + let declared = package_layout::plugins(); + let repo_root = manifest_dir + .parent() + .and_then(|app_root| app_root.parent()) + .and_then(|apps_dir| apps_dir.parent()) + .expect("AGC 应用必须位于仓库 apps 目录下"); + let destination_root = manifest_dir.join(declared.destination_directory); + for plugin in package_layout::plugin_directories( + &repo_root.join(declared.source_directory), + declared.manifest_file_name, + ) + .unwrap_or_else(|error| panic!("{error}")) + { + for subdirectory in declared.subdirectories { + if !package_layout::subdirectory_is_prepared(subdirectory) + || !package_layout::subdirectory_applies_to_plugin(subdirectory, &plugin.name) + || !package_layout::subdirectory_enabled( + subdirectory, + target, + package_layout::cargo_feature_enabled, + ) + { + continue; + } + let relative = package_layout::declared_relative_path(subdirectory.path); + let staged = destination_root.join(&plugin.name).join(&relative); + if !staged.is_dir() { + panic!( + "随包已准备产物缺失:{}(请先执行随包资源准备步骤)", + staged.display() + ); + } + } + for staging in declared.library_staging { + if plugin.name != staging.plugin + || !package_layout::library_staging_enabled( + staging, + target, + package_layout::cargo_feature_enabled, + ) + { + continue; + } + let relative = package_layout::declared_relative_path(staging.source_subdirectory); + let staged = destination_root.join(&plugin.name).join(&relative); + godot_bundle::validate(&staged) + .unwrap_or_else(|error| panic!("Godot 随包资源校验失败:{error}")); + } + } +} + +fn validate_staged_plugin_workspace(manifest_dir: &std::path::Path, target: &str) { + let declared = package_layout::plugins(); + let repo_root = manifest_dir + .parent() + .and_then(|app_root| app_root.parent()) + .and_then(|apps_dir| apps_dir.parent()) + .expect("AGC 应用必须位于仓库 apps 目录下"); + package_layout::validate_staged_plugins( + &repo_root.join(declared.source_directory), + &manifest_dir.join(declared.destination_directory), + target, + package_layout::cargo_feature_enabled, + ) + .unwrap_or_else(|error| panic!("插件随包资源校验失败:{error}")); +} fn main() { let manifest_dir = PathBuf::from( env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be available"), ); let manifest_path = manifest_dir.join("prompts/runtime/manifest.json"); - stage_bundled_claude_agent(&manifest_dir); - stage_bundled_codex_cli(&manifest_dir); - prepare_unity_editor_helper(&manifest_dir); - prepare_godot_editor_extension(&manifest_dir); - stage_plugin_workspace(&manifest_dir); - stage_cocos_editor_payload(&manifest_dir); + // 运行期定位随包目录依赖该编译期常量,与是否跳过 staging 无关(见技术方案 §4.4)。 + println!( + "cargo:rustc-env=AGC_BUILD_TARGET={}", + env::var("TARGET").expect("Cargo TARGET") + ); + // Claude Agent SDK 的版本只声明一处,运行期的 sidecar 身份串由此取值。 + println!( + "cargo:rustc-env=AGC_CLAUDE_AGENT_SDK_VERSION={}", + package_layout::claude_agent().version + ); + validate_staged_resources(&manifest_dir); let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path) .unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}")); validate_seed_task_catalog(&compiled); @@ -366,315 +203,3 @@ fn main() { } tauri_build::build() } - -#[cfg(windows)] -fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) { - if std::env::var_os("CARGO_FEATURE_COCOS_EDITOR_INJECTION").is_none() { - return; - } - let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")); - let profile_dir = out_dir - .ancestors() - .find(|path| path.file_name().is_some_and(|name| name == "build")) - .and_then(|build_dir| build_dir.parent()) - .expect("AGC Cargo profile directory not found"); - let candidates = [ - profile_dir.join("deps/cocos_editor_bridge.dll"), - profile_dir.join("cocos_editor_bridge.dll"), - ]; - let source = candidates - .iter() - .find(|path| path.is_file()) - .unwrap_or_else(|| { - panic!( - "Cocos bridge native payload 未构建:{}", - candidates - .iter() - .map(|p| p.display().to_string()) - .collect::>() - .join(";") - ) - }); - for destination in [ - // 插件工作区里的 payload 是开发态与打包态的唯一真源。 - manifest_dir - .join("../../../plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"), - // 随包资源目录与 tauri.windows.conf.json 的 `resources/plugins` 映射保持一致。 - manifest_dir - .join("resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"), - ] { - std::fs::create_dir_all(destination.parent().expect("payload resource parent")) - .expect("创建 Cocos bridge payload 目录失败"); - std::fs::copy(source, &destination).expect("复制 Cocos bridge native payload 失败"); - } - println!("cargo:rerun-if-changed={}", source.display()); -} - -#[cfg(not(windows))] -fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {} - -/// Unity helper 是插件的随包运行文件。内容指纹避免每次 Cargo 检查都重新发布 .NET。 -fn prepare_unity_editor_helper(manifest_dir: &std::path::Path) { - println!("cargo:rerun-if-env-changed=CARGO_FEATURE_UNITY_EDITOR_EXECUTE"); - let target = env::var("TARGET").expect("Cargo TARGET"); - if env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none() - || target != "x86_64-pc-windows-msvc" - { - return; - } - let root = manifest_dir.join("../../../plugins/agc-unity-editor/dotnet"); - let mut sources = Vec::new(); - collect_unity_helper_sources(&root, &mut sources); - sources.sort(); - let mut fingerprint = Sha256::new(); - for source in &sources { - println!("cargo:rerun-if-changed={}", source.display()); - fingerprint.update( - source - .strip_prefix(&root) - .expect("helper source") - .to_string_lossy() - .as_bytes(), - ); - fingerprint.update([0]); - fingerprint.update(fs::read(source).expect("读取 Unity helper 源文件失败")); - } - let fingerprint = format!("{:x}", fingerprint.finalize()); - let publish = root.join("publish/win-x64"); - let executable = publish.join("Agc.Unity.Attach.exe"); - let stamp = publish.join(".agc-source.sha256"); - println!("cargo:rerun-if-changed={}", executable.display()); - if unity_helper_publish_complete(&publish) - && fs::read_to_string(&stamp).ok().as_deref() == Some(&fingerprint) - { - return; - } - assert!( - cfg!(windows), - "构建 Unity 插件 helper 需要 Windows .NET 10 与 x64 C++ 工具链" - ); - let status = std::process::Command::new("powershell.exe") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - ]) - .arg(root.join("build.ps1")) - .current_dir(&root) - .status() - .expect("无法启动 Unity helper 构建脚本"); - assert!( - status.success() && unity_helper_publish_complete(&publish), - "Unity helper 构建失败或缺少运行文件/许可" - ); - fs::write(stamp, fingerprint).expect("写入 Unity helper 构建指纹失败"); -} - -fn unity_helper_publish_complete(publish: &std::path::Path) -> bool { - [ - "Agc.Unity.Attach.exe", - "NOTICE", - "THIRD-PARTY-NOTICES.txt", - "licenses/DotCraft-Apache-2.0.txt", - "licenses/Roslyn-MIT.txt", - "licenses/upstream.json", - "licenses/dotnet-LICENSE.TXT", - "licenses/dotnet-THIRD-PARTY-NOTICES.TXT", - "licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf", - "licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf", - ] - .iter() - .all(|name| { - fs::symlink_metadata(publish.join(name)).is_ok_and(|metadata| { - metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() > 0 - }) - }) -} - -fn collect_unity_helper_sources(root: &std::path::Path, sources: &mut Vec) { - for entry in fs::read_dir(root) - .expect("Unity helper 源码目录缺失") - .flatten() - { - let kind = entry.file_type().expect("读取 Unity helper 源文件类型失败"); - assert!(!kind.is_symlink(), "Unity helper 源码不允许符号链接"); - let name = entry.file_name(); - if kind.is_dir() { - if !matches!( - name.to_str(), - Some("bin" | "obj" | "publish" | "native-build") - ) { - collect_unity_helper_sources(&entry.path(), sources); - } - } else if kind.is_file() { - sources.push(entry.path()); - } - } -} - -fn prepare_godot_editor_extension(manifest_dir: &std::path::Path) { - println!("cargo:rerun-if-env-changed=CARGO_FEATURE_GODOT_EDITOR_EXECUTE"); - if env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_none() - || env::var("TARGET").expect("Cargo TARGET") != "x86_64-pc-windows-msvc" - { - return; - } - let root = manifest_dir.join("../../../plugins/agc-godot-editor/native/gdextension"); - for source in godot_bundle::source_files(&root).unwrap_or_else(|error| panic!("{error}")) { - println!("cargo:rerun-if-changed={}", source.display()); - } - assert!( - cfg!(windows), - "构建 Godot 原生扩展需要 Windows x64 C 编译器" - ); - let status = std::process::Command::new("powershell.exe") - // Cargo 可能从 PowerShell 7 启动,Windows PowerShell 应使用自身模块目录。 - .env_remove("PSModulePath") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - ]) - .arg(root.join("build.ps1")) - .current_dir(&root) - .status() - .expect("无法启动 Godot 原生扩展构建脚本"); - assert!(status.success(), "Godot 原生扩展构建失败"); - godot_bundle::validate(&root).unwrap_or_else(|error| panic!("{error}")); -} - -/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。 -/// -/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、 -/// Cargo target 目录或 node_modules。 -fn stage_plugin_workspace(manifest_dir: &std::path::Path) { - let target = env::var("TARGET").expect("Cargo TARGET"); - if !target.contains("windows") && !target.contains("apple-darwin") { - return; - } - let repo_root = manifest_dir - .parent() - .and_then(|app_root| app_root.parent()) - .and_then(|apps_dir| apps_dir.parent()) - .expect("AGC 应用必须位于仓库 apps 目录下") - .to_path_buf(); - let workspace = repo_root.join("plugins"); - let destination_root = manifest_dir.join("resources/plugins"); - // staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。 - if destination_root.exists() { - std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败"); - } - std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败"); - let entries = match std::fs::read_dir(&workspace) { - Ok(entries) => entries, - Err(_) => return, - }; - for entry in entries.flatten() { - let plugin_root = entry.path(); - assert!( - !entry - .file_type() - .expect("读取插件目录类型失败") - .is_symlink(), - "插件工作区不允许符号链接" - ); - if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() { - continue; - } - let name = entry.file_name(); - let destination = destination_root.join(&name); - copy_plugin_file( - &plugin_root.join("plugin.json"), - &destination.join("plugin.json"), - ); - for relative in [ - std::path::PathBuf::from("src"), - std::path::PathBuf::from("panels"), - std::path::PathBuf::from("skills"), - std::path::PathBuf::from("native/payload"), - std::path::PathBuf::from("dotnet/publish/win-x64"), - ] { - if (relative == std::path::Path::new("native/payload") && !target.contains("windows")) - || (relative == std::path::Path::new("dotnet/publish/win-x64") - && (target != "x86_64-pc-windows-msvc" - || env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none())) - { - continue; - } - copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); - } - if name == "agc-godot-editor" { - godot_bundle::stage( - &plugin_root.join("native/gdextension"), - &destination.join("native/gdextension"), - &target, - env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_some(), - ) - .unwrap_or_else(|error| panic!("{error}")); - } - println!("cargo:rerun-if-changed={}", plugin_root.display()); - } -} - -fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) { - let bytes = std::fs::read(source) - .unwrap_or_else(|error| panic!("读取随包资源失败 {}:{error}", source.display())); - if std::fs::read(destination).is_ok_and(|existing| existing == bytes) { - return; - } - if let Some(parent) = destination.parent() { - std::fs::create_dir_all(parent).expect("创建插件资源目录失败"); - } - std::fs::write(destination, bytes).expect("复制插件资源失败"); -} - -fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { - let entries = match std::fs::read_dir(source) { - Ok(entries) => entries, - Err(_) => return, - }; - for entry in entries.flatten() { - let target = destination.join(entry.file_name()); - let path = entry.path(); - assert!( - !entry - .file_type() - .expect("读取插件文件类型失败") - .is_symlink(), - "插件资源不允许符号链接" - ); - if path.is_dir() { - let name = entry.file_name(); - let name = name.to_string_lossy(); - if name.starts_with('.') || matches!(name.as_ref(), "target" | "node_modules") { - continue; - } - std::fs::create_dir_all(&target).expect("创建插件资源目录失败"); - copy_plugin_tree(&path, &target); - } else { - // 测试文件不随包分发。 - let name = entry.file_name(); - let name = name.to_string_lossy(); - if name.contains(".test.") { - continue; - } - if name.starts_with('.') { - continue; - } - stage_plugin_file(&path, &target); - } - } -} - -fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) { - if !source.is_file() { - return; - } - std::fs::create_dir_all(destination.parent().expect("插件资源父目录")) - .expect("创建插件资源目录失败"); - std::fs::copy(source, destination).expect("复制插件资源失败"); -} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs index e2064d28d..d802866df 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs @@ -1,8 +1,18 @@ //! 构建与运行共用的平台布局;只允许分发锁定原生包里的明确组件。 +//! +//! 布局、组件白名单与版本常量来自唯一声明 `build_support/package-layout.json` +//! (Rust 侧经 `build_support/package-layout.generated.rs` 取得编译期常量, +//! 由 `scripts/check-package-layout.mjs` 生成并在门禁中校验一致)。 +//! 本模块只读声明,不写任何随包资源。 -pub const VERSION: &str = "0.155.1"; -pub const CLI_VERSION: &str = "codex-cli 0.155.1"; -pub const SCHEMA: &str = "genarrative-codex-sidecar.v2"; +// 共享声明模块:构建脚本、运行期与测试各自只用到其中一部分,未用到的入口不算缺陷。 +#[allow(dead_code)] +#[path = "package_layout.rs"] +pub(crate) mod package_layout; + +pub const VERSION: &str = package_layout::CODEX_VERSION; +pub const CLI_VERSION: &str = package_layout::CODEX_CLI_VERSION; +pub const SCHEMA: &str = package_layout::CODEX_MANIFEST_SCHEMA; #[derive(Clone, Copy, Debug)] pub struct Layout { @@ -12,46 +22,13 @@ pub struct Layout { pub files: &'static [&'static str], } -const WINDOWS_FILES: &[&str] = &[ - "bin/codex.exe", - "bin/codex-code-mode-host.exe", - "codex-path/rg.exe", - "codex-resources/codex-command-runner.exe", - "codex-resources/codex-windows-sandbox-setup.exe", - "codex-package.json", -]; -const MAC_FILES: &[&str] = &[ - "bin/codex", - "bin/codex-code-mode-host", - "codex-path/rg", - "codex-resources/zsh/bin/zsh", - "codex-package.json", -]; - pub fn for_target(target: &str) -> Option { - match target { - "x86_64-pc-windows-msvc" => Some(Layout { - platform: "win32-x64", - directory: "win-x64", - executable: "bin/codex.exe", - files: WINDOWS_FILES, - }), - "aarch64-apple-darwin" | "x86_64-apple-darwin" => Some(Layout { - platform: if target.starts_with("aarch64") { - "darwin-arm64" - } else { - "darwin-x64" - }, - directory: if target.starts_with("aarch64") { - "mac-native/darwin-arm64" - } else { - "mac-native/darwin-x64" - }, - executable: "bin/codex", - files: MAC_FILES, - }), - _ => None, - } + package_layout::codex_target(target).map(|declared| Layout { + platform: declared.platform, + directory: declared.directory, + executable: declared.executable, + files: declared.files, + }) } #[cfg(test)] @@ -80,4 +57,11 @@ mod tests { assert!(for_target("aarch64-pc-windows-msvc").is_none()); assert!(for_target("x86_64-unknown-linux-gnu").is_none()); } + + #[test] + fn constants_come_from_the_shared_declaration() { + assert_eq!(VERSION, "0.155.1"); + assert_eq!(CLI_VERSION, format!("codex-cli {VERSION}")); + assert_eq!(SCHEMA, "genarrative-codex-sidecar.v2"); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/codex_package_metadata.rs b/apps/ai-game-creator-shell/src-tauri/build_support/codex_package_metadata.rs deleted file mode 100644 index 04b9b6a41..000000000 --- a/apps/ai-game-creator-shell/src-tauri/build_support/codex_package_metadata.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! 随包阶段的原生包元数据校验,不进入运行时生产模块。 - -use super::codex_bundle::{Layout, VERSION}; - -pub fn validate_package_metadata( - metadata: &serde_json::Value, - target: &str, - layout: Layout, -) -> Result<(), String> { - if metadata["layoutVersion"] == 1 - && metadata["version"] == VERSION - && metadata["target"] == target - && metadata["entrypoint"] == layout.executable - && metadata["resourcesDir"] == "codex-resources" - && metadata["pathDir"] == "codex-path" - { - Ok(()) - } else { - Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}")) - } -} - -#[cfg(test)] -mod tests { - use super::super::codex_bundle::for_target; - use super::*; - - #[test] - fn metadata_rejects_version_architecture_and_layout_drift() { - let target = "aarch64-apple-darwin"; - let layout = for_target(target).unwrap(); - let valid = serde_json::json!({ - "layoutVersion": 1, - "version": VERSION, - "target": target, - "entrypoint": "bin/codex", - "resourcesDir": "codex-resources", - "pathDir": "codex-path", - }); - assert!(validate_package_metadata(&valid, target, layout).is_ok()); - for (key, value) in [ - ("layoutVersion", serde_json::json!(2)), - ("version", serde_json::json!("0.0.0")), - ("target", serde_json::json!("x86_64-apple-darwin")), - ("entrypoint", serde_json::json!("bin/codex.exe")), - ("resourcesDir", serde_json::json!("../private")), - ("pathDir", serde_json::json!(null)), - ] { - let mut invalid = valid.clone(); - invalid[key] = value; - assert!( - validate_package_metadata(&invalid, target, layout).is_err(), - "{key}" - ); - } - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs index 8a326ac98..0c0061d5b 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs @@ -1,13 +1,13 @@ use sha2::{Digest, Sha256}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; -pub const BUNDLE_FILES: [&str; 4] = [ - "bin/win-x64/agc_godot_editor.dll", - "bin/win-x64/metadata.json", - "vendor/LICENSE.txt", - "vendor/provenance.json", -]; +// 共享随包资源声明:Godot 随包文件清单与构建期校验共用同一份来源。 +#[allow(dead_code)] +#[path = "package_layout.rs"] +mod package_layout; + +pub const BUNDLE_FILES: &[&str] = package_layout::GODOT_BUNDLE_FILES; fn plain_metadata(path: &Path) -> Result { let metadata = fs::symlink_metadata(path) @@ -74,39 +74,6 @@ pub fn validate(root: &Path) -> Result)>, String> { Ok(files) } -pub fn stage(root: &Path, destination: &Path, target: &str, enabled: bool) -> Result<(), String> { - if target != "x86_64-pc-windows-msvc" || !enabled { - return Ok(()); - } - for (relative, bytes) in validate(root)? { - let path = destination.join(relative); - fs::create_dir_all(path.parent().expect("Godot resource parent")) - .map_err(|error| format!("创建 Godot 资源目录失败:{error}"))?; - fs::write(&path, bytes).map_err(|error| format!("写入 Godot 资源失败:{error}"))?; - } - Ok(()) -} - -pub fn source_files(root: &Path) -> Result, String> { - plain_metadata(root)?; - let mut sources = Vec::new(); - for entry in fs::read_dir(root).map_err(|error| format!("读取 Godot 源码失败:{error}"))? - { - let entry = entry.map_err(|error| format!("读取 Godot 源码目录项失败:{error}"))?; - if matches!(entry.file_name().to_str(), Some("bin" | ".build")) { - continue; - } - let metadata = plain_metadata(&entry.path())?; - if metadata.is_dir() { - sources.extend(source_files(&entry.path())?); - } else if metadata.is_file() { - sources.push(entry.path()); - } - } - sources.sort(); - Ok(sources) -} - #[cfg(test)] mod tests { use super::*; @@ -134,82 +101,20 @@ mod tests { } #[test] - fn stage_only_verified_windows_runtime_and_not_build_inputs() { - let source = tempfile::tempdir().unwrap(); - let destination = tempfile::tempdir().unwrap(); - fixture(source.path()); - fs::write(source.path().join("bridge.gd"), "source").unwrap(); - fs::write(source.path().join("bin/win-x64/extra.dll"), "excluded").unwrap(); - stage( - source.path(), - destination.path(), - "x86_64-pc-windows-msvc", - true, - ) - .unwrap(); + fn validate_rejects_incomplete_bundle() { + let temp = tempfile::tempdir().expect("tempdir"); + let error = validate(temp.path()).expect_err("空目录必须被拒绝"); + assert!(!error.is_empty(), "失败原因不能为空"); + + // 只有文件名、内容不合法的「像样」目录也必须被拒绝。 for relative in BUNDLE_FILES { - assert_eq!( - fs::read(source.path().join(relative)).unwrap(), - fs::read(destination.path().join(relative)).unwrap() - ); + let file = temp.path().join(relative); + fs::create_dir_all(file.parent().expect("parent")).expect("create dir"); + fs::write(&file, b"not a real artifact").expect("write file"); } - assert!(!destination.path().join("bridge.gd").exists()); - assert!(!destination.path().join("bin/win-x64/extra.dll").exists()); - } - - #[test] - fn unsupported_or_disabled_targets_need_no_native_artifacts() { - let destination = tempfile::tempdir().unwrap(); - for (target, enabled) in [ - ("aarch64-apple-darwin", true), - ("x86_64-apple-darwin", true), - ("x86_64-unknown-linux-gnu", true), - ("aarch64-pc-windows-msvc", true), - ("x86_64-pc-windows-msvc", false), - ] { - stage( - Path::new("missing-godot-native"), - destination.path(), - target, - enabled, - ) - .unwrap(); - assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0); - } - } - - #[test] - fn incomplete_or_tampered_bundle_fails_before_copying() { - let source = tempfile::tempdir().unwrap(); - let destination = tempfile::tempdir().unwrap(); - fixture(source.path()); - fs::write(source.path().join(BUNDLE_FILES[0]), b"tampered").unwrap(); - assert!(stage( - source.path(), - destination.path(), - "x86_64-pc-windows-msvc", - true - ) - .unwrap_err() - .contains("SHA256")); - assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0); - fixture(source.path()); - fs::remove_file(source.path().join("vendor/LICENSE.txt")).unwrap(); - assert!(validate(source.path()).is_err()); - } - - #[test] - fn source_watch_list_excludes_build_outputs() { - let source = tempfile::tempdir().unwrap(); - fixture(source.path()); - fs::create_dir(source.path().join(".build")).unwrap(); - fs::write(source.path().join(".build/bridge.obj"), "generated").unwrap(); - fs::write(source.path().join("bridge.gd"), "source").unwrap(); - let sources = source_files(source.path()).unwrap(); - assert_eq!(sources.len(), 3); - assert!(sources.contains(&source.path().join("bridge.gd"))); - assert!(!sources.iter().any(|path| path - .components() - .any(|component| component.as_os_str() == "bin" || component.as_os_str() == ".build"))); + assert!( + validate(temp.path()).is_err(), + "内容不合法的随包库必须被拒绝", + ); } } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs new file mode 100644 index 000000000..931290eb7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -0,0 +1,163 @@ +// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs +// 来源:build_support/package-layout.json。不要手工编辑本文件。 +// 修改随包资源布局请编辑声明文件,然后运行 +// npm run agc:bundled-resources:sync(在仓库根目录) +// 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。 + +pub const DECLARATION_SCHEMA: &str = "agc-package-layout.v1"; +pub const LAYOUT_VERSION: u64 = 2; + +pub const CODEX_VERSION: &str = "0.155.1"; +pub const CODEX_CLI_VERSION: &str = "codex-cli 0.155.1"; +pub const CODEX_MANIFEST_SCHEMA: &str = "genarrative-codex-sidecar.v2"; + +pub const CLAUDE_AGENT_SDK_VERSION: &str = "0.3.285"; + +pub const GODOT_BUNDLE_FILES: &[&str] = &["bin/win-x64/agc_godot_editor.dll", "bin/win-x64/metadata.json", "vendor/LICENSE.txt", "vendor/provenance.json"]; + +pub const CODEX: Codex = Codex { + package_metadata: PackageMetadata { + layout_version: 1, + resources_dir: "codex-resources", + path_dir: "codex-path", +}, + resource_directory: "resources/codex", + manifest_file_name: "manifest.json", + package_metadata_file_name: "codex-package.json", + notice_file_name: "NOTICE.md", + source_roots: &["app", "repo"], + source_relative_paths: &["node_modules/@openai/codex-/vendor/", "node_modules/@openai/codex/node_modules/@openai/codex-/vendor/"], + notice_sources: &[ +NoticeSource { + targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"], + source: "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md", + preserve: false, + }, +NoticeSource { + targets: &["x86_64-pc-windows-msvc"], + source: "resources/codex/win-x64/NOTICE.md", + preserve: true, + } +], + universal_groups: &[ +UniversalGroup { + name: "mac-native", + directory: "mac-native", + targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"], + } +], + targets: &[ +CodexTarget { + target: "x86_64-pc-windows-msvc", + platform: "win32-x64", + directory: "win-x64", + executable: "bin/codex.exe", + files: &["bin/codex.exe", "bin/codex-code-mode-host.exe", "codex-path/rg.exe", "codex-resources/codex-command-runner.exe", "codex-resources/codex-windows-sandbox-setup.exe", "codex-package.json"], + }, +CodexTarget { + target: "aarch64-apple-darwin", + platform: "darwin-arm64", + directory: "mac-native/darwin-arm64", + executable: "bin/codex", + files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"], + }, +CodexTarget { + target: "x86_64-apple-darwin", + platform: "darwin-x64", + directory: "mac-native/darwin-x64", + executable: "bin/codex", + files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"], + } +], +}; + +pub const CLAUDE_AGENT: ClaudeAgent = ClaudeAgent { + version: "0.3.285", + resource_directory: "resources/claude-agent", + entry_relative_path: "agent-sidecar/src/index.mjs", + entry_file_name: "index.mjs", + node_modules_directory: "node_modules", + sdk_package_name: "@anthropic-ai/claude-agent-sdk", + sdk_entry_file_name: "sdk.mjs", + sdk_package_metadata_file_name: "package.json", + targets: &[ +ClaudeAgentTarget { + target: "x86_64-pc-windows-msvc", + runtime_package: "claude-agent-sdk-win32-x64", + runtime_file_name: "claude.exe", + }, +ClaudeAgentTarget { + target: "aarch64-apple-darwin", + runtime_package: "claude-agent-sdk-darwin-arm64", + runtime_file_name: "claude", + }, +ClaudeAgentTarget { + target: "x86_64-apple-darwin", + runtime_package: "claude-agent-sdk-darwin-x64", + runtime_file_name: "claude", + } +], +}; + +pub const PLUGINS: Plugins = Plugins { + source_directory: "plugins", + destination_directory: "resources/plugins", + manifest_file_name: "plugin.json", + target_contains_any: &["windows", "apple-darwin"], + subdirectories: &[ +Subdirectory { + path: "src", + plugin: "", + origin: "source", + target_contains: &[], + targets: &[], + features: &[], + }, +Subdirectory { + path: "panels", + plugin: "", + origin: "source", + target_contains: &[], + targets: &[], + features: &[], + }, +Subdirectory { + path: "skills", + plugin: "", + origin: "source", + target_contains: &[], + targets: &[], + features: &[], + }, +Subdirectory { + path: "native/payload", + plugin: "agc-cocos-editor", + origin: "prepared", + target_contains: &["windows"], + targets: &[], + features: &["cocos-editor-injection"], + }, +Subdirectory { + path: "dotnet/publish/win-x64", + plugin: "agc-unity-editor", + origin: "prepared", + target_contains: &[], + targets: &["x86_64-pc-windows-msvc"], + features: &["unity-editor-execute"], + } +], + library_staging: &[ +LibraryStaging { + plugin: "agc-godot-editor", + source_subdirectory: "native/gdextension", + targets: &["x86_64-pc-windows-msvc"], + features: &["godot-editor-execute"], + layout: "godot-bundle", + files: &["bin/win-x64/agc_godot_editor.dll", "bin/win-x64/metadata.json", "vendor/LICENSE.txt", "vendor/provenance.json"], + } +], + skip_directory_names: &["target", "node_modules"], + skip_directory_name_prefixes: &["."], + skip_file_name_prefixes: &["."], + skip_file_name_fragments: &[".test."], +}; diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json new file mode 100644 index 000000000..1729262db --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json @@ -0,0 +1,234 @@ +{ + "schema": "agc-package-layout.v1", + "description": "AGC 随包资源布局与复制规则的唯一声明。Rust 侧构建期校验与 Node 侧准备步骤共用本文件,任何一侧都不得再写第二份布局或组件白名单。含 、 占位符的字段由调用方按目标三元展开。修改布局时同步递增 layoutVersion(准备步骤的缓存 key 组成部分)。", + "layoutVersion": 2, + "codex": { + "version": "0.155.1", + "cliVersionPrefix": "codex-cli ", + "manifestSchema": "genarrative-codex-sidecar.v2", + "packageMetadata": { + "layoutVersion": 1, + "resourcesDir": "codex-resources", + "pathDir": "codex-path" + }, + "resourceDirectory": "resources/codex", + "manifestFileName": "manifest.json", + "packageMetadataFileName": "codex-package.json", + "noticeFileName": "NOTICE.md", + "sourceRoots": ["app", "repo"], + "sourceRelativePaths": [ + "node_modules/@openai/codex-/vendor/", + "node_modules/@openai/codex/node_modules/@openai/codex-/vendor/" + ], + "noticeSources": [ + { + "targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"], + "source": "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md", + "preserve": false + }, + { + "targets": ["x86_64-pc-windows-msvc"], + "source": "resources/codex/win-x64/NOTICE.md", + "preserve": true + } + ], + "universalGroups": [ + { + "name": "mac-native", + "directory": "mac-native", + "targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"] + } + ], + "targets": [ + { + "target": "x86_64-pc-windows-msvc", + "platform": "win32-x64", + "directory": "win-x64", + "executable": "bin/codex.exe", + "files": [ + "bin/codex.exe", + "bin/codex-code-mode-host.exe", + "codex-path/rg.exe", + "codex-resources/codex-command-runner.exe", + "codex-resources/codex-windows-sandbox-setup.exe", + "codex-package.json" + ] + }, + { + "target": "aarch64-apple-darwin", + "platform": "darwin-arm64", + "directory": "mac-native/darwin-arm64", + "executable": "bin/codex", + "files": [ + "bin/codex", + "bin/codex-code-mode-host", + "codex-path/rg", + "codex-resources/zsh/bin/zsh", + "codex-package.json" + ] + }, + { + "target": "x86_64-apple-darwin", + "platform": "darwin-x64", + "directory": "mac-native/darwin-x64", + "executable": "bin/codex", + "files": [ + "bin/codex", + "bin/codex-code-mode-host", + "codex-path/rg", + "codex-resources/zsh/bin/zsh", + "codex-package.json" + ] + } + ] + }, + "claudeAgent": { + "version": "0.3.285", + "resourceDirectory": "resources/claude-agent", + "entryRelativePath": "agent-sidecar/src/index.mjs", + "entryFileName": "index.mjs", + "nodeModulesDirectory": "node_modules", + "sdkPackageName": "@anthropic-ai/claude-agent-sdk", + "sdkEntryFileName": "sdk.mjs", + "sdkPackageMetadataFileName": "package.json", + "sourceRoots": ["app", "repo"], + "skipDirectoryNames": ["target", "node_modules"], + "skipDirectoryNamePrefixes": ["."], + "skipFileNamePrefixes": ["."], + "skipFileNameFragments": [".test."], + "targets": [ + { + "target": "x86_64-pc-windows-msvc", + "runtimePackage": "claude-agent-sdk-win32-x64", + "runtimeFileName": "claude.exe" + }, + { + "target": "aarch64-apple-darwin", + "runtimePackage": "claude-agent-sdk-darwin-arm64", + "runtimeFileName": "claude" + }, + { + "target": "x86_64-apple-darwin", + "runtimePackage": "claude-agent-sdk-darwin-x64", + "runtimeFileName": "claude" + } + ] + }, + "plugins": { + "sourceDirectory": "plugins", + "destinationDirectory": "resources/plugins", + "manifestFileName": "plugin.json", + "targetContainsAny": ["windows", "apple-darwin"], + "subdirectories": [ + { + "path": "src", + "origin": "source" + }, + { + "path": "panels", + "origin": "source" + }, + { + "path": "skills", + "origin": "source" + }, + { + "path": "native/payload", + "origin": "prepared", + "targetContains": ["windows"], + "plugin": "agc-cocos-editor", + "prepare": "cocos-bridge-build", + "features": ["cocos-editor-injection"] + }, + { + "path": "dotnet/publish/win-x64", + "origin": "prepared", + "prepare": "unity-helper-publish", + "targets": ["x86_64-pc-windows-msvc"], + "features": ["unity-editor-execute"], + "plugin": "agc-unity-editor" + } + ], + "libraryStaging": [ + { + "plugin": "agc-godot-editor", + "sourceSubdirectory": "native/gdextension", + "prepare": "godot-extension-build", + "targets": ["x86_64-pc-windows-msvc"], + "features": ["godot-editor-execute"], + "layout": "godot-bundle", + "files": [ + "bin/win-x64/agc_godot_editor.dll", + "bin/win-x64/metadata.json", + "vendor/LICENSE.txt", + "vendor/provenance.json" + ] + } + ], + "nativePayloads": [ + { + "plugin": "agc-cocos-editor", + "prepare": "cocos-bridge-build", + "sourceFileName": "cocos_editor_bridge.dll", + "destinationSubdirectory": "native/payload", + "targets": ["x86_64-pc-windows-msvc"], + "features": ["cocos-editor-injection"], + "destinationFileName": "cocos-editor-bridge.dll" + } + ], + "prepareSteps": [ + { + "name": "unity-helper-publish", + "kind": "powershell", + "workingDirectory": "plugins/agc-unity-editor/dotnet", + "scriptFileName": "build.ps1", + "fingerprint": { + "roots": ["."], + "excludeDirectoryNames": ["bin", "obj", "publish", "native-build"], + "stampRelativePath": "publish/win-x64/.agc-source.sha256" + }, + "requiredOutputs": [ + "plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe", + "plugins/agc-unity-editor/dotnet/publish/win-x64/NOTICE", + "plugins/agc-unity-editor/dotnet/publish/win-x64/THIRD-PARTY-NOTICES.txt", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/DotCraft-Apache-2.0.txt", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/Roslyn-MIT.txt", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/upstream.json", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/dotnet-LICENSE.TXT", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/dotnet-THIRD-PARTY-NOTICES.TXT", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf" + ] + }, + { + "name": "godot-extension-build", + "kind": "powershell", + "workingDirectory": "plugins/agc-godot-editor/native/gdextension", + "scriptFileName": "build.ps1", + "removeEnvironment": ["PSModulePath"], + "requiredOutputs": [ + "plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll", + "plugins/agc-godot-editor/native/gdextension/bin/win-x64/metadata.json", + "plugins/agc-godot-editor/native/gdextension/vendor/LICENSE.txt", + "plugins/agc-godot-editor/native/gdextension/vendor/provenance.json" + ], + "fingerprint": { + "roots": ["."], + "excludeDirectoryNames": ["bin", ".build", "native-build"], + "stampRelativePath": "bin/win-x64/.agc-source.sha256" + } + }, + { + "name": "cocos-bridge-build", + "kind": "cargo", + "packageDirectory": "plugins/agc-cocos-editor/native/cocos-editor-bridge", + "features": ["windows-injection"], + "requiredOutputs": [] + } + ], + "skipDirectoryNames": ["target", "node_modules"], + "skipDirectoryNamePrefixes": ["."], + "skipFileNamePrefixes": ["."], + "skipFileNameFragments": [".test."] + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs new file mode 100644 index 000000000..ddd54ee55 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -0,0 +1,1641 @@ +//! 随包资源布局与复制规则的单一声明读取层,以及随包产物的只读校验。 +//! +//! 人工声明只有一处:`build_support/package-layout.json`。Rust 侧使用由 +//! `scripts/check-package-layout.mjs` 从该声明生成的编译期常量 +//! (`package-layout.generated.rs`,门禁校验两者一致),Node 侧准备步骤直接读声明本身。 +//! 本模块**只读**:既不解析 JSON,也不写任何随包资源。 + +use std::collections::BTreeSet; +use std::io::{BufReader, Read}; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +/// 目标三元 → 平台包与组件白名单。 +#[derive(Debug)] +pub struct CodexTarget { + pub target: &'static str, + pub platform: &'static str, + pub directory: &'static str, + pub executable: &'static str, + pub files: &'static [&'static str], +} + +/// 一次构建共用的整目录(例如 macOS 的 `mac-native` 双架构)。 +#[derive(Debug)] +pub struct UniversalGroup { + pub name: &'static str, + pub directory: &'static str, + pub targets: &'static [&'static str], +} + +/// 第三方声明的来源;`preserve` 表示该文件受版本控制、只允许原地保留。 +#[derive(Debug)] +pub struct NoticeSource { + pub targets: &'static [&'static str], + pub source: &'static str, + pub preserve: bool, +} + +/// 上游原生包元数据里必须与声明一致的字段。 +#[derive(Debug)] +pub struct PackageMetadata { + pub layout_version: u64, + pub resources_dir: &'static str, + pub path_dir: &'static str, +} + +/// 插件工作区的随包子目录及其生效条件。 +#[derive(Debug)] +pub struct Subdirectory { + pub path: &'static str, + /// 该子目录归属的插件名;空串表示适用于所有插件。 + pub plugin: &'static str, + /// `source`:由声明与仓库源码就能生成(准备步骤负责);`build`:构建期工具链产出(构建脚本负责)。 + pub origin: &'static str, + pub target_contains: &'static [&'static str], + pub targets: &'static [&'static str], + pub features: &'static [&'static str], +} + +/// 由外部工具链另行产出的随包库(如 Godot gdextension)的归位规则。 +/// +/// `files` 是相对 `source_subdirectory` 的随包文件清单:源码工作区同位目录里还有构建输入与 +/// 测试,只有这些文件会归位到随包目录,校验也以这份清单为准。 +#[derive(Debug)] +pub struct LibraryStaging { + pub plugin: &'static str, + pub source_subdirectory: &'static str, + pub targets: &'static [&'static str], + pub features: &'static [&'static str], + pub layout: &'static str, + pub files: &'static [&'static str], +} + +#[derive(Debug)] +pub struct Codex { + pub package_metadata: PackageMetadata, + pub resource_directory: &'static str, + pub manifest_file_name: &'static str, + pub package_metadata_file_name: &'static str, + pub notice_file_name: &'static str, + pub source_roots: &'static [&'static str], + pub source_relative_paths: &'static [&'static str], + pub notice_sources: &'static [NoticeSource], + pub universal_groups: &'static [UniversalGroup], + pub targets: &'static [CodexTarget], +} + +#[derive(Debug)] +pub struct Plugins { + pub source_directory: &'static str, + pub destination_directory: &'static str, + pub manifest_file_name: &'static str, + pub target_contains_any: &'static [&'static str], + pub subdirectories: &'static [Subdirectory], + pub library_staging: &'static [LibraryStaging], + pub skip_directory_names: &'static [&'static str], + pub skip_directory_name_prefixes: &'static [&'static str], + pub skip_file_name_prefixes: &'static [&'static str], + pub skip_file_name_fragments: &'static [&'static str], +} + +/// Claude Agent SDK sidecar 在某个目标上的原生运行时包。 +#[derive(Debug)] +pub struct ClaudeAgentTarget { + pub target: &'static str, + pub runtime_package: &'static str, + pub runtime_file_name: &'static str, +} + +/// Claude Agent SDK sidecar 的随包布局:应用仓库入口 + 两个上游 npm 包。 +#[derive(Debug)] +pub struct ClaudeAgent { + pub version: &'static str, + pub resource_directory: &'static str, + pub entry_relative_path: &'static str, + pub entry_file_name: &'static str, + pub node_modules_directory: &'static str, + pub sdk_package_name: &'static str, + pub sdk_entry_file_name: &'static str, + pub sdk_package_metadata_file_name: &'static str, + pub targets: &'static [ClaudeAgentTarget], +} + +include!("package-layout.generated.rs"); + +const PLATFORM_PLACEHOLDER: &str = ""; +const TARGET_PLACEHOLDER: &str = ""; + +pub fn codex() -> &'static Codex { + &CODEX +} + +pub fn plugins() -> &'static Plugins { + &PLUGINS +} + +pub fn claude_agent() -> &'static ClaudeAgent { + &CLAUDE_AGENT +} + +/// 声明里的 Claude Agent SDK sidecar 目标;未声明的目标返回 `None`(该目标不随包 sidecar)。 +pub fn claude_agent_target(target: &str) -> Option<&'static ClaudeAgentTarget> { + CLAUDE_AGENT + .targets + .iter() + .find(|entry| entry.target == target) +} + +/// 声明里的目标布局;未声明的目标返回 `None`。 +pub fn codex_target(target: &str) -> Option<&'static CodexTarget> { + CODEX.targets.iter().find(|entry| entry.target == target) +} + +/// 目标所属的整目录分组(macOS 双架构共用 `mac-native`)。 +pub fn codex_universal_group(target: &str) -> Option<&'static UniversalGroup> { + CODEX + .universal_groups + .iter() + .find(|group| group.targets.contains(&target)) +} + +/// 目标对应的第三方声明来源。 +pub fn codex_notice_source(target: &str) -> Option<&'static NoticeSource> { + CODEX + .notice_sources + .iter() + .find(|entry| entry.targets.contains(&target)) +} + +/// 单次构建需要校验的平台目录:属于整目录分组时是该组全部三元,否则是自身;不支持的目标为空。 +pub fn staged_targets(target: &str) -> Vec<&'static str> { + if let Some(group) = codex_universal_group(target) { + return group.targets.to_vec(); + } + codex_target(target) + .map(|declared| vec![declared.target]) + .unwrap_or_default() +} + +/// 上游平台包的候选路径,顺序与声明一致(先 app 目录后仓库根,各自按声明顺序)。 +pub fn codex_source_candidates( + app_root: &Path, + repo_root: &Path, + target: &str, +) -> Result, String> { + let layout = + codex_target(target).ok_or_else(|| format!("声明不含目标 {target} 的 Codex 布局"))?; + let mut candidates = Vec::new(); + for root in CODEX.source_roots { + let base = match *root { + "app" => app_root, + "repo" => repo_root, + other => return Err(format!("声明中的 sourceRoots 取值无效:{other}")), + }; + for relative in CODEX.source_relative_paths { + let expanded = relative + .replace(PLATFORM_PLACEHOLDER, layout.platform) + .replace(TARGET_PLACEHOLDER, target); + candidates.push(base.join(expanded)); + } + } + Ok(candidates) +} + +/// 插件随包 staging 是否适用于该目标(与声明里的平台门槛一致)。 +pub fn plugin_staging_applies(target: &str) -> bool { + PLUGINS + .target_contains_any + .iter() + .any(|needle| target.contains(needle)) +} + +/// 子目录是否归属该插件(空串表示通用)。 +pub fn subdirectory_applies_to_plugin(subdirectory: &Subdirectory, plugin_name: &str) -> bool { + subdirectory.plugin.is_empty() || subdirectory.plugin == plugin_name +} + +/// 子目录在当前目标与已启用 feature 下是否随包。 +pub fn subdirectory_enabled( + subdirectory: &Subdirectory, + target: &str, + feature_enabled: impl Fn(&str) -> bool, +) -> bool { + (subdirectory.target_contains.is_empty() + || subdirectory + .target_contains + .iter() + .any(|needle| target.contains(needle))) + && (subdirectory.targets.is_empty() || subdirectory.targets.contains(&target)) + && subdirectory + .features + .iter() + .all(|name| feature_enabled(name)) +} + +/// 随包库归位在当前目标与已启用 feature 下是否生效。 +pub fn library_staging_enabled( + staging: &LibraryStaging, + target: &str, + feature_enabled: impl Fn(&str) -> bool, +) -> bool { + (staging.targets.is_empty() || staging.targets.contains(&target)) + && staging.features.iter().all(|name| feature_enabled(name)) +} + +/// Cargo build script 上下文里该 feature 是否启用(`CARGO_FEATURE_`)。 +pub fn cargo_feature_enabled(name: &str) -> bool { + let variable = format!("CARGO_FEATURE_{}", name.to_uppercase().replace('-', "_")); + std::env::var_os(variable).is_some() +} + +/// 目录是否被跳过(构建产物目录与隐藏目录)。 +pub fn skip_directory(name: &str) -> bool { + PLUGINS.skip_directory_names.contains(&name) + || PLUGINS + .skip_directory_name_prefixes + .iter() + .any(|prefix| name.starts_with(prefix)) +} + +/// 文件是否被跳过(测试文件与隐藏文件)。 +pub fn skip_file_name(name: &str) -> bool { + PLUGINS + .skip_file_name_prefixes + .iter() + .any(|prefix| name.starts_with(prefix)) + || PLUGINS + .skip_file_name_fragments + .iter() + .any(|fragment| name.contains(fragment)) +} + +/// 逐块 sha256;构建期校验与 staging 比对共用同一实现。 +pub fn sha256_file(path: &Path) -> Result { + let file = std::fs::File::open(path)?; + let mut reader = BufReader::new(file); + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +/// 声明里的相对路径一律使用 `/`,落盘时转换为平台分隔符。 +pub fn declared_relative_path(relative: &str) -> PathBuf { + relative.split('/').collect() +} + +/// 只读校验插件随包工作区。 +/// +/// 校验分两层:先按当前目标与已启用 feature 构造允许文件集合(插件清单、生效的随包子目录、 +/// 生效的随包库),再要求随包目录由这些文件恰好组成——清单与源码派生的子目录逐文件 sha256 +/// 一致,构建期派生的子目录与源码工作区同位目录逐文件对齐(内容由产出它的构建步骤负责), +/// 随包库的声明文件齐备;集合之外的任何文件一律拒绝(未声明的根条目、源码子目录里的残留文件、 +/// 未启用 feature 的产物)。整树不得出现符号链接,本函数不做任何写入。 +pub fn validate_staged_plugins( + source_root: &Path, + destination_root: &Path, + target: &str, + feature_enabled: impl Fn(&str) -> bool, +) -> Result<(), String> { + if !plugin_staging_applies(target) { + return Ok(()); + } + let metadata = std::fs::symlink_metadata(destination_root).map_err(|error| { + format!( + "插件随包资源目录不可读 {}:{error}", + destination_root.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!( + "插件随包资源目录缺失或不允许符号链接:{}", + destination_root.display() + )); + } + + let declared_plugins = plugin_directories(source_root, plugins().manifest_file_name)?; + let declared_names = declared_plugins + .iter() + .map(|plugin| plugin.name.as_str()) + .collect::>(); + for entry in std::fs::read_dir(destination_root).map_err(|error| { + format!( + "插件随包资源目录不可读 {}:{error}", + destination_root.display() + ) + })? { + let entry = entry.map_err(|error| format!("插件随包目录项不可读:{error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("插件随包目录项类型不可读:{error}"))?; + if file_type.is_symlink() { + return Err(format!( + "插件随包目录不允许符号链接:{}", + entry.path().display() + )); + } + let name = entry.file_name().to_string_lossy().to_string(); + if !declared_names.contains(name.as_str()) { + return Err(format!( + "插件随包目录存在未声明条目:{}", + entry.path().display() + )); + } + } + for plugin in &declared_plugins { + let staged = destination_root.join(&plugin.name); + let source_manifest = plugin.path.join(plugins().manifest_file_name); + let staged_manifest = staged.join(plugins().manifest_file_name); + if !staged_manifest.is_file() { + return Err(format!("随包插件缺少清单:{}", staged_manifest.display())); + } + let source_manifest_digest = sha256_file(&source_manifest) + .map_err(|error| format!("读取插件清单失败 {}:{error}", source_manifest.display()))?; + let staged_manifest_digest = sha256_file(&staged_manifest).map_err(|error| { + format!( + "读取随包插件清单失败 {}:{error}", + staged_manifest.display() + ) + })?; + if source_manifest_digest != staged_manifest_digest { + return Err(format!( + "随包插件清单与源码不一致:{}", + staged_manifest.display() + )); + } + let mut allowed = BTreeSet::new(); + allowed.insert(plugins().manifest_file_name.to_string()); + for subdirectory in plugins().subdirectories { + if !subdirectory_applies_to_plugin(subdirectory, &plugin.name) + || !subdirectory_enabled(subdirectory, target, &feature_enabled) + { + continue; + } + let relative = declared_relative_path(subdirectory.path); + let source_directory = plugin.path.join(&relative); + let staged_directory = staged.join(&relative); + for file in source_subdirectory_files(&source_directory)? { + allowed.insert(format!("{}/{}", subdirectory.path, file)); + let staged_file = staged_directory.join(declared_relative_path(&file)); + if !staged_file.is_file() { + return Err(if subdirectory_is_prepared(subdirectory) { + format!( + "随包已准备产物缺少文件:{}(请先执行随包资源准备步骤)", + staged_file.display() + ) + } else { + format!("随包插件缺少文件:{}", staged_file.display()) + }); + } + if !subdirectory_is_source_derived(subdirectory) { + continue; + } + let source_file = source_directory.join(declared_relative_path(&file)); + let source_digest = sha256_file(&source_file).map_err(|error| { + format!("读取插件文件失败 {}:{error}", source_file.display()) + })?; + let staged_digest = sha256_file(&staged_file).map_err(|error| { + format!("读取随包插件文件失败 {}:{error}", staged_file.display()) + })?; + if source_digest != staged_digest { + return Err(format!( + "随包插件文件与源码不一致:{}", + staged_file.display() + )); + } + } + } + for staging in plugins().library_staging { + if staging.plugin != plugin.name.as_str() + || !library_staging_enabled(staging, target, &feature_enabled) + { + continue; + } + for file in staging.files { + let relative = format!("{}/{}", staging.source_subdirectory, file); + let staged_file = staged.join(declared_relative_path(&relative)); + if !staged_file.is_file() { + return Err(format!( + "随包库缺少声明文件:{}(请先执行随包资源准备步骤)", + staged_file.display() + )); + } + allowed.insert(relative); + } + } + for relative in collect_tree_files(&staged)? { + if !allowed.contains(&relative) { + return Err(format!( + "随包插件存在未声明文件:{}(目标 {target} 与当前 feature 组合不允许;请先执行随包资源准备步骤)", + staged.join(declared_relative_path(&relative)).display() + )); + } + } + } + collect_tree_files(destination_root)?; + Ok(()) +} + +/// 源码工作区同位子目录里的文件集合;该目录不存在时为空集。 +fn source_subdirectory_files(source_directory: &Path) -> Result, String> { + if !source_directory.is_dir() { + return Ok(BTreeSet::new()); + } + collect_sources(source_directory) +} + +/// 声明为「由准备步骤按源码派生」的子目录。 +pub fn subdirectory_is_source_derived(subdirectory: &Subdirectory) -> bool { + subdirectory.origin == "source" +} + +/// 声明为「需要先由准备步骤运行构建命令产出」的子目录。 +pub fn subdirectory_is_prepared(subdirectory: &Subdirectory) -> bool { + subdirectory.origin == "prepared" +} + +/// 仓库插件目录:含清单文件的普通目录,按名字排序。 +pub fn plugin_directories( + source_root: &Path, + manifest_file_name: &str, +) -> Result, String> { + let mut plugins = Vec::new(); + let entries = std::fs::read_dir(source_root) + .map_err(|error| format!("插件工作区不可读 {}:{error}", source_root.display()))?; + for entry in entries { + let entry = entry.map_err(|error| format!("插件目录项不可读:{error}"))?; + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|error| format!("插件目录项类型不可读:{error}"))?; + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() && path.join(manifest_file_name).is_file() { + plugins.push(PluginDirectory { name, path }); + } + } + plugins.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(plugins) +} + +pub struct PluginDirectory { + pub name: String, + pub path: PathBuf, +} + +/// 按声明的跳过规则收集目录下的文件(相对路径,`/` 分隔);遇到符号链接即失败。 +pub fn collect_sources(root: &Path) -> Result, String> { + let mut files = BTreeSet::new(); + let mut stack = vec![(root.to_path_buf(), String::new())]; + while let Some((directory, prefix)) = stack.pop() { + let entries = std::fs::read_dir(&directory) + .map_err(|error| format!("随包资源源码不可读 {}:{error}", directory.display()))?; + for entry in entries { + let entry = entry.map_err(|error| format!("随包资源目录项不可读:{error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("随包资源目录项类型不可读:{error}"))?; + let name = entry.file_name().to_string_lossy().to_string(); + let relative = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + if file_type.is_symlink() { + return Err(format!("随包资源不允许符号链接:{relative}")); + } + if file_type.is_dir() { + if !skip_directory(&name) { + stack.push((entry.path(), relative)); + } + } else if !skip_file_name(&name) { + files.insert(relative); + } + } + } + Ok(files) +} + +/// 只读校验一份已经落盘的 Codex 随包目录(本函数不写任何文件)。 +/// +/// 校验项:目录存在且非链接、清单存在且 schema/平台/版本一致、清单文件集合与组件白名单完全一致、 +/// 每个组件存在且摘要一致、第三方声明存在、可执行组件具备可执行位、目录内不存在白名单外的文件。 +pub fn validate_staged_codex_bundle(target_dir: &Path, target: &str) -> Result<(), String> { + let layout = + codex_target(target).ok_or_else(|| format!("声明不含目标 {target} 的 Codex 布局"))?; + let metadata = std::fs::symlink_metadata(target_dir) + .map_err(|error| format!("随包目录不可读 {}:{error}", target_dir.display()))?; + if !metadata.is_dir() { + return Err(format!("随包目录不是目录:{}", target_dir.display())); + } + if metadata.file_type().is_symlink() { + return Err(format!("随包目录不允许符号链接:{}", target_dir.display())); + } + + let manifest_path = target_dir.join(CODEX.manifest_file_name); + let manifest_raw = std::fs::read_to_string(&manifest_path) + .map_err(|error| format!("随包清单不可读 {}:{error}", manifest_path.display()))?; + let manifest: serde_json::Value = serde_json::from_str(&manifest_raw) + .map_err(|error| format!("随包清单不是合法 JSON {}:{error}", manifest_path.display()))?; + let schema = manifest["schemaVersion"].as_str().unwrap_or_default(); + if schema != CODEX_MANIFEST_SCHEMA { + return Err(format!( + "随包清单 schema 不受支持:{schema}(期望 {CODEX_MANIFEST_SCHEMA})" + )); + } + let platform = manifest["platform"].as_str().unwrap_or_default(); + if platform != layout.platform { + return Err(format!( + "随包清单平台与目标不一致:{platform}(目标 {target} 期望 {})", + layout.platform + )); + } + let version = manifest["version"].as_str().unwrap_or_default(); + if version != CODEX_CLI_VERSION { + return Err(format!( + "随包清单版本与声明不一致:{version}(期望 {CODEX_CLI_VERSION})" + )); + } + let hashes = manifest["files"] + .as_object() + .ok_or_else(|| format!("随包清单缺少 files 映射:{}", manifest_path.display()))?; + let declared = layout + .files + .iter() + .map(|relative| (*relative).to_string()) + .collect::>(); + let listed = hashes.keys().cloned().collect::>(); + if declared != listed { + let missing = declared.difference(&listed).cloned().collect::>(); + let unexpected = listed.difference(&declared).cloned().collect::>(); + return Err(format!( + "随包清单文件集合与组件白名单不一致(缺少 {missing:?},多出 {unexpected:?})" + )); + } + + for relative in layout.files { + let path = target_dir.join(declared_relative_path(relative)); + let file_metadata = std::fs::symlink_metadata(&path) + .map_err(|error| format!("随包组件缺失 {relative}:{error}"))?; + if file_metadata.file_type().is_symlink() { + return Err(format!("随包组件不允许符号链接:{relative}")); + } + if !file_metadata.is_file() { + return Err(format!("随包组件不是普通文件:{relative}")); + } + if file_metadata.len() == 0 { + return Err(format!("随包组件为空:{relative}")); + } + let expected = hashes + .get(*relative) + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(format!("随包清单摘要格式无效:{relative} = {expected}")); + } + let actual = + sha256_file(&path).map_err(|error| format!("读取随包组件失败 {relative}:{error}"))?; + if !actual.eq_ignore_ascii_case(expected) { + return Err(format!( + "随包组件摘要与清单不一致:{relative}(清单 {expected},实际 {actual})" + )); + } + #[cfg(unix)] + if *relative == layout.executable { + use std::os::unix::fs::PermissionsExt; + if file_metadata.permissions().mode() & 0o111 == 0 { + return Err(format!("随包可执行组件缺少可执行位:{relative}")); + } + } + } + + let notice = target_dir.join(CODEX.notice_file_name); + if !notice.is_file() { + return Err(format!("随包第三方声明缺失:{}", notice.display())); + } + + let mut allowed = declared.clone(); + allowed.insert(CODEX.notice_file_name.to_string()); + allowed.insert(CODEX.manifest_file_name.to_string()); + let actual = collect_tree_files(target_dir)?; + let unexpected = actual.difference(&allowed).cloned().collect::>(); + if !unexpected.is_empty() { + return Err(format!( + "随包目录存在白名单外的文件:{unexpected:?}({})", + target_dir.display() + )); + } + Ok(()) +} + +/// 只读校验 Claude Agent SDK sidecar 的随包产物(由准备步骤写入)。 +/// +/// 上游 npm 包由应用依赖与 lockfile 锁定,准备步骤整目录替换,因此构建期校验的是: +/// 入口与仓库源码逐字节一致、SDK 版本与声明一致、平台原生运行时在位、目录里没有白名单外的内容。 +pub fn validate_staged_claude_agent( + target_dir: &Path, + app_root: &Path, + target: &str, +) -> Result<(), String> { + let declared = claude_agent_target(target) + .ok_or_else(|| format!("声明不含目标 {target} 的 Claude Agent SDK sidecar 布局"))?; + let metadata = std::fs::symlink_metadata(target_dir) + .map_err(|error| format!("随包目录不可读 {}:{error}", target_dir.display()))?; + if !metadata.is_dir() { + return Err(format!("随包目录不是目录:{}", target_dir.display())); + } + if metadata.file_type().is_symlink() { + return Err(format!("随包目录不允许符号链接:{}", target_dir.display())); + } + + let entry = target_dir.join(CLAUDE_AGENT.entry_file_name); + let entry_source = app_root.join(declared_relative_path(CLAUDE_AGENT.entry_relative_path)); + let staged_entry = std::fs::read(&entry).map_err(|error| { + format!( + "Claude Agent SDK sidecar 入口不可读 {}:{error}", + entry.display() + ) + })?; + if staged_entry.is_empty() { + return Err(format!( + "Claude Agent SDK sidecar 入口为空:{}", + entry.display() + )); + } + let repository_entry = std::fs::read(&entry_source).map_err(|error| { + format!( + "Claude Agent SDK sidecar 源码入口不可读 {}:{error}", + entry_source.display() + ) + })?; + if staged_entry != repository_entry { + return Err(format!( + "Claude Agent SDK sidecar 入口与仓库源码不一致:{}(源码 {})", + entry.display(), + entry_source.display() + )); + } + + let scope = package_scope(CLAUDE_AGENT.sdk_package_name); + let sdk_package = claude_agent_node_modules(target_dir) + .join(declared_relative_path(CLAUDE_AGENT.sdk_package_name)); + validate_upstream_package_version( + &sdk_package, + CLAUDE_AGENT.sdk_package_metadata_file_name, + CLAUDE_AGENT.version, + "Claude Agent SDK", + )?; + let sdk_entry = sdk_package.join(CLAUDE_AGENT.sdk_entry_file_name); + if !is_staged_file(&sdk_entry) { + return Err(format!( + "Claude Agent SDK sidecar 缺少 SDK 入口:{}", + sdk_entry.display() + )); + } + + let runtime_package = claude_agent_node_modules(target_dir) + .join(declared_relative_path(scope)) + .join(declared_relative_path(declared.runtime_package)); + validate_upstream_package_version( + &runtime_package, + CLAUDE_AGENT.sdk_package_metadata_file_name, + CLAUDE_AGENT.version, + "Claude Agent SDK 原生运行时", + )?; + let runtime_file = runtime_package.join(declared_relative_path(declared.runtime_file_name)); + if !is_staged_file(&runtime_file) { + return Err(format!( + "Claude Agent SDK native runtime 缺失:{}", + runtime_file.display() + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if std::fs::metadata(&runtime_file) + .map(|metadata| metadata.permissions().mode() & 0o111 == 0) + .unwrap_or(true) + { + return Err(format!( + "Claude Agent SDK native runtime 缺少可执行位:{}", + runtime_file.display() + )); + } + } + + let allowed_prefixes = [ + format!( + "{}/{}", + CLAUDE_AGENT.node_modules_directory, CLAUDE_AGENT.sdk_package_name + ), + format!( + "{}/{}/{}", + CLAUDE_AGENT.node_modules_directory, scope, declared.runtime_package + ), + ]; + for relative in collect_tree_files(target_dir)? { + if relative == CLAUDE_AGENT.entry_file_name + || allowed_prefixes + .iter() + .any(|prefix| relative.starts_with(&format!("{prefix}/"))) + { + continue; + } + return Err(format!( + "Claude Agent SDK sidecar 随包目录存在白名单外的文件:{relative}({})", + target_dir.display() + )); + } + Ok(()) +} + +/// 随包 sidecar 的 `node_modules` 目录。 +fn claude_agent_node_modules(target_dir: &Path) -> PathBuf { + target_dir.join(declared_relative_path(CLAUDE_AGENT.node_modules_directory)) +} + +/// `@scope/pkg` 的 scope 段;声明门禁保证 SDK 包名带 scope。 +fn package_scope(package_name: &str) -> &str { + package_name.split('/').next().unwrap_or(package_name) +} + +/// 普通文件(非符号链接)且非空。 +fn is_staged_file(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|metadata| { + metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() > 0 + }) +} + +/// 上游包元数据里的版本必须与声明一致:上游换版本而声明未同步时,构建期立即失败。 +fn validate_upstream_package_version( + package_dir: &Path, + metadata_file_name: &str, + expected: &str, + label: &str, +) -> Result<(), String> { + let metadata_path = package_dir.join(metadata_file_name); + let raw = std::fs::read_to_string(&metadata_path) + .map_err(|error| format!("{label} 元数据不可读 {}:{error}", metadata_path.display()))?; + let metadata: serde_json::Value = serde_json::from_str(&raw).map_err(|error| { + format!( + "{label} 元数据不是合法 JSON {}:{error}", + metadata_path.display() + ) + })?; + let version = metadata["version"].as_str().unwrap_or_default(); + if version != expected { + return Err(format!( + "{label} 版本与声明不一致:{version}(期望 {expected},{});请同步更新 build_support/package-layout.json", + metadata_path.display() + )); + } + Ok(()) +} + +/// 递归收集目录下的普通文件(相对路径,`/` 分隔);遇到符号链接即失败。 +pub fn collect_tree_files(root: &Path) -> Result, String> { + let mut files = BTreeSet::new(); + let mut stack = vec![(root.to_path_buf(), String::new())]; + while let Some((directory, prefix)) = stack.pop() { + let entries = std::fs::read_dir(&directory) + .map_err(|error| format!("随包目录不可读 {}:{error}", directory.display()))?; + for entry in entries { + let entry = entry.map_err(|error| format!("随包目录项不可读:{error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("随包目录项类型不可读:{error}"))?; + let name = entry.file_name().to_string_lossy().to_string(); + let relative = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + if file_type.is_symlink() { + return Err(format!("随包资源不允许符号链接:{relative}")); + } + if file_type.is_dir() { + stack.push((entry.path(), relative)); + } else { + files.insert(relative); + } + } + } + Ok(files) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn declared_target() -> &'static CodexTarget { + codex_target("aarch64-apple-darwin").expect("mac layout") + } + + /// 在临时目录里生成一份合规的 Codex 随包目录。 + fn write_bundle(root: &Path, target: &str) -> serde_json::Value { + let layout = codex_target(target).expect("layout"); + let mut hashes = serde_json::Map::new(); + for relative in layout.files { + let path = root.join(declared_relative_path(relative)); + fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + fs::write(&path, format!("component {relative}")).expect("write component"); + #[cfg(unix)] + if *relative == layout.executable { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod"); + } + hashes.insert( + relative.to_string(), + serde_json::Value::String(sha256_file(&path).expect("hash")), + ); + } + fs::write(root.join(CODEX.notice_file_name), "notice").expect("write notice"); + let manifest = serde_json::json!({ + "schemaVersion": CODEX_MANIFEST_SCHEMA, + "platform": layout.platform, + "version": CODEX_CLI_VERSION, + "files": hashes, + }); + fs::write( + root.join(CODEX.manifest_file_name), + serde_json::to_string_pretty(&manifest).expect("serialize"), + ) + .expect("write manifest"); + manifest + } + + #[test] + fn valid_bundle_passes() { + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin").expect("valid bundle"); + } + + #[test] + fn rejects_manifest_schema_platform_and_version_drift() { + for (key, value) in [ + ("schemaVersion", serde_json::json!("other-sidecar.v9")), + ("platform", serde_json::json!("darwin-x64")), + ("version", serde_json::json!("codex-cli 0.0.0")), + ] { + let temp = tempfile::tempdir().expect("tempdir"); + let mut manifest = write_bundle(temp.path(), "aarch64-apple-darwin"); + manifest[key] = value; + fs::write( + temp.path().join(CODEX.manifest_file_name), + serde_json::to_string_pretty(&manifest).expect("serialize"), + ) + .expect("write manifest"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject"); + assert!(error.contains("清单"), "{key}: {error}"); + } + } + + #[test] + fn rejects_missing_component_and_digest_drift() { + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + let executable = declared_target().executable; + fs::remove_file(temp.path().join(declared_relative_path(executable))).expect("remove"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject missing component"); + assert!(error.contains(executable), "{error}"); + + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + let path = temp.path().join("codex-package.json"); + fs::write(&path, "tampered").expect("tamper"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject digest drift"); + assert!(error.contains("摘要"), "{error}"); + } + + #[test] + fn rejects_undeclared_file_and_missing_notice() { + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + fs::write(temp.path().join("stray.bin"), "stray").expect("write stray"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject stray file"); + assert!(error.contains("白名单外"), "{error}"); + + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + fs::remove_file(temp.path().join(CODEX.notice_file_name)).expect("remove notice"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject missing notice"); + assert!(error.contains("第三方声明"), "{error}"); + } + + #[cfg(unix)] + #[test] + fn rejects_symlinked_component_and_missing_executable_bit() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + let executable = temp.path().join("bin/codex"); + fs::remove_file(&executable).expect("remove"); + symlink("codex-code-mode-host", &executable).expect("symlink"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject symlink"); + assert!(error.contains("符号链接"), "{error}"); + + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + let executable = temp.path().join("bin/codex"); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o644)).expect("chmod"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject missing executable bit"); + assert!(error.contains("可执行位"), "{error}"); + } + + #[test] + fn unsupported_target_is_rejected() { + let temp = tempfile::tempdir().expect("tempdir"); + let error = validate_staged_codex_bundle(temp.path(), "x86_64-unknown-linux-gnu") + .expect_err("must reject unsupported target"); + assert!(error.contains("声明不含目标"), "{error}"); + } + + #[test] + fn declaration_pins_codex_layout() { + assert_eq!(DECLARATION_SCHEMA, "agc-package-layout.v1"); + assert_eq!(LAYOUT_VERSION, 2); + assert_eq!(CODEX_VERSION, "0.155.1"); + assert_eq!(CODEX_CLI_VERSION, "codex-cli 0.155.1"); + assert_eq!(CODEX.targets.len(), 3); + let windows = codex_target("x86_64-pc-windows-msvc").expect("windows layout"); + assert_eq!(windows.platform, "win32-x64"); + assert_eq!(windows.directory, "win-x64"); + assert_eq!(windows.executable, "bin/codex.exe"); + assert_eq!(windows.files.len(), 6); + assert_eq!(declared_target().files.len(), 5); + assert!(declared_target() + .files + .contains(&"codex-resources/zsh/bin/zsh")); + assert!(codex_target("universal-apple-darwin").is_none()); + assert!(codex_target("x86_64-unknown-linux-gnu").is_none()); + } + + #[test] + fn universal_group_covers_both_darwin_targets() { + let group = codex_universal_group("aarch64-apple-darwin").expect("darwin group"); + assert_eq!(group.directory, "mac-native"); + assert_eq!( + group.targets, + ["aarch64-apple-darwin", "x86_64-apple-darwin"] + ); + assert_eq!(staged_targets("aarch64-apple-darwin").len(), 2); + assert_eq!( + staged_targets("x86_64-pc-windows-msvc"), + ["x86_64-pc-windows-msvc"] + ); + assert!(staged_targets("x86_64-unknown-linux-gnu").is_empty()); + } + + #[test] + fn notice_sources_cover_every_declared_target() { + for target in ["x86_64-pc-windows-msvc", "aarch64-apple-darwin"] { + assert!( + codex_notice_source(target).is_some(), + "缺少声明来源:{target}" + ); + } + assert!( + codex_notice_source("x86_64-pc-windows-msvc") + .expect("windows notice") + .preserve + ); + assert!( + !codex_notice_source("x86_64-apple-darwin") + .expect("mac notice") + .preserve + ); + } + + #[test] + fn source_candidates_follow_declared_order() { + let rendered = codex_source_candidates( + Path::new("/app"), + Path::new("/repo"), + "aarch64-apple-darwin", + ) + .expect("candidates") + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>(); + // 期望值与实现同形:实现是「根目录 join 整条声明的相对路径」,路径内部的 `/` 不会被 + // Windows 改写;按组件逐段 join 会在 Windows 上产出纯反斜杠,让该用例必红。 + let expected = |root: &str, nested: bool| { + let relative = if nested { + "node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin" + } else { + "node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin" + }; + Path::new(root).join(relative).to_string_lossy().to_string() + }; + assert_eq!( + rendered, + [ + expected("/app", false), + expected("/app", true), + expected("/repo", false), + expected("/repo", true), + ] + ); + assert!(codex_source_candidates( + Path::new("/app"), + Path::new("/repo"), + "x86_64-unknown-linux-gnu" + ) + .is_err()); + } + + #[test] + fn plugin_rules_match_declared_whitelist() { + assert_eq!(PLUGINS.subdirectories.len(), 5); + assert_eq!(PLUGINS.subdirectories[0].path, "src"); + let payload = PLUGINS + .subdirectories + .iter() + .find(|entry| entry.path == "native/payload") + .expect("native/payload"); + assert!(subdirectory_enabled( + payload, + "x86_64-pc-windows-msvc", + |_| true + )); + assert!(!subdirectory_enabled( + payload, + "aarch64-apple-darwin", + |_| true + )); + let unity = PLUGINS + .subdirectories + .iter() + .find(|entry| entry.path == "dotnet/publish/win-x64") + .expect("unity publish"); + assert!(subdirectory_enabled( + unity, + "x86_64-pc-windows-msvc", + |name| name == "unity-editor-execute" + )); + assert!(!subdirectory_enabled( + unity, + "x86_64-pc-windows-msvc", + |_| false + )); + assert!(!subdirectory_enabled(unity, "aarch64-apple-darwin", |_| { + true + })); + assert!(plugin_staging_applies("x86_64-pc-windows-msvc")); + assert!(plugin_staging_applies("aarch64-apple-darwin")); + assert!(!plugin_staging_applies("x86_64-unknown-linux-gnu")); + assert_eq!(PLUGINS.source_directory, "plugins"); + assert_eq!(PLUGINS.destination_directory, "resources/plugins"); + assert_eq!(PLUGINS.manifest_file_name, "plugin.json"); + } + + #[test] + fn library_staging_rules_are_declared() { + let staging = PLUGINS + .library_staging + .iter() + .find(|entry| entry.layout == "godot-bundle") + .expect("godot staging"); + assert_eq!(staging.plugin, "agc-godot-editor"); + assert_eq!(staging.source_subdirectory, "native/gdextension"); + assert!(library_staging_enabled( + staging, + "x86_64-pc-windows-msvc", + |name| name == "godot-editor-execute" + )); + assert!(!library_staging_enabled( + staging, + "x86_64-pc-windows-msvc", + |_| false + )); + assert!(!library_staging_enabled( + staging, + "aarch64-apple-darwin", + |_| true + )); + } + + #[test] + fn skip_rules_match_copy_semantics() { + assert!(skip_directory("target")); + assert!(skip_directory("node_modules")); + assert!(skip_directory(".git")); + assert!(!skip_directory("src")); + assert!(skip_file_name(".env")); + assert!(skip_file_name("runner.test.mjs")); + assert!(!skip_file_name("runner.mjs")); + } + + #[test] + fn plugin_subdirectories_declare_their_origin() { + let source = PLUGINS + .subdirectories + .iter() + .filter(|entry| subdirectory_is_source_derived(entry)) + .map(|entry| entry.path) + .collect::>(); + let prepared = PLUGINS + .subdirectories + .iter() + .filter(|entry| subdirectory_is_prepared(entry)) + .map(|entry| entry.path) + .collect::>(); + assert_eq!(source, ["src", "panels", "skills"]); + assert_eq!(prepared, ["native/payload", "dotnet/publish/win-x64"]); + } + + #[test] + fn package_metadata_expectations_come_from_the_declaration() { + assert_eq!(CODEX.package_metadata.layout_version, 1); + assert_eq!(CODEX.package_metadata.resources_dir, "codex-resources"); + assert_eq!(CODEX.package_metadata.path_dir, "codex-path"); + } + + /// 源码与随包目录各写一份插件夹具:随包侧缺测试文件、带一个构建期产物目录。 + fn write_plugin_fixture(source_root: &Path, destination_root: &Path) { + let plugin_source = source_root.join("agc-demo-editor"); + fs::create_dir_all(plugin_source.join("src")).expect("create src"); + fs::write( + plugin_source.join("plugin.json"), + "{\"name\":\"agc-demo-editor\"}\n", + ) + .expect("write manifest"); + fs::write(plugin_source.join("src/entry.mjs"), "export const a = 1;\n") + .expect("write entry"); + fs::write(plugin_source.join("src/entry.test.mjs"), "test\n").expect("write test file"); + fs::create_dir_all(plugin_source.join("native/gdextension")).expect("create gdextension"); + + let staged = destination_root.join("agc-demo-editor"); + fs::create_dir_all(staged.join("src")).expect("create staged src"); + fs::write( + staged.join("plugin.json"), + "{\"name\":\"agc-demo-editor\"}\n", + ) + .expect("write staged manifest"); + fs::write(staged.join("src/entry.mjs"), "export const a = 1;\n") + .expect("write staged entry"); + } + + /// 在源码与随包目录各写一份同名同内容的插件目录(随包侧即准备步骤的复制结果)。 + fn write_plugin_copy( + source_root: &Path, + destination_root: &Path, + plugin_name: &str, + relative_files: &[&str], + ) { + for root in [source_root, destination_root] { + let plugin = root.join(plugin_name); + fs::create_dir_all(&plugin).expect("create plugin"); + fs::write( + plugin.join("plugin.json"), + format!("{{\"name\":\"{plugin_name}\"}}\n"), + ) + .expect("write manifest"); + for relative in relative_files { + let path = plugin.join(declared_relative_path(relative)); + fs::create_dir_all(path.parent().expect("path parent")).expect("create parent"); + fs::write(&path, format!("{relative}\n")).expect("write file"); + } + } + } + + #[test] + fn staged_plugins_are_checked_against_repository_sources() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_fixture(&source_root, &destination_root); + validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect("valid plugin workspace"); + + let entry = destination_root.join("agc-demo-editor/src/entry.mjs"); + fs::write(&entry, "tampered\n").expect("tamper"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("must reject digest drift"); + assert!(error.contains("不一致"), "{error}"); + + fs::write(&entry, "export const a = 1;\n").expect("restore"); + fs::remove_file(&entry).expect("remove"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("must reject missing file"); + assert!(error.contains("缺少文件"), "{error}"); + + fs::write(&entry, "export const a = 1;\n").expect("restore"); + fs::remove_file(destination_root.join("agc-demo-editor/plugin.json")) + .expect("remove manifest"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("must reject missing manifest"); + assert!(error.contains("缺少清单"), "{error}"); + + validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-unknown-linux-gnu", + |_| true, + ) + .expect("不支持的目标直接放行"); + } + + #[cfg(unix)] + #[test] + fn staged_plugins_reject_symlinks() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_fixture(&source_root, &destination_root); + symlink( + "entry.mjs", + destination_root.join("agc-demo-editor/src/link.mjs"), + ) + .expect("symlink"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("must reject symlink"); + assert!(error.contains("符号链接"), "{error}"); + } + + /// 随包目录里出现源码侧不存在的文件(源码子目录残留、插件根目录未知文件、未声明的根条目)必须被拒绝。 + #[test] + fn staged_plugins_reject_undeclared_files() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_fixture(&source_root, &destination_root); + + let leftover = destination_root.join("agc-demo-editor/src/leftover.mjs"); + fs::write(&leftover, "stale\n").expect("write leftover"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("必须拒绝源码子目录里的残留文件"); + assert!(error.contains("未声明文件"), "{error}"); + assert!(error.contains("leftover.mjs"), "{error}"); + fs::remove_file(&leftover).expect("remove leftover"); + + let root_file = destination_root.join("agc-demo-editor/README.md"); + fs::write(&root_file, "stale\n").expect("write plugin root file"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("必须拒绝插件根目录的未知文件"); + assert!(error.contains("未声明文件"), "{error}"); + fs::remove_file(&root_file).expect("remove plugin root file"); + + let stray = destination_root.join("stray.txt"); + fs::write(&stray, "stale\n").expect("write stray entry"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("必须拒绝未声明的根条目"); + assert!(error.contains("未声明条目"), "{error}"); + } + + /// 源码侧删掉文件后,随包目录里的旧副本属于未声明文件(随包目录不接受比源码多出的内容)。 + #[test] + fn staged_plugins_reject_files_removed_from_sources() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_fixture(&source_root, &destination_root); + fs::remove_file(source_root.join("agc-demo-editor/src/entry.mjs")).expect("remove source"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("源码删除后的随包副本必须被拒绝"); + assert!(error.contains("未声明文件"), "{error}"); + assert!(error.contains("entry.mjs"), "{error}"); + } + + /// Cocos 的 `native/payload` 由构建产出:只在 windows 且 feature 开启时随包,其余组合下其产物必须被拒绝。 + #[test] + fn staged_plugins_reject_prepared_artifacts_of_disabled_features() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_copy( + &source_root, + &destination_root, + "agc-cocos-editor", + &["src/entry.mjs", "native/payload/cocos-editor-bridge.dll"], + ); + validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |name| name == "cocos-editor-injection", + ) + .expect("feature 开启时 prepared 产物合法"); + + let error = validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |_| false, + ) + .expect_err("feature 关闭后必须拒绝 prepared 产物"); + assert!(error.contains("未声明文件"), "{error}"); + assert!(error.contains("cocos-editor-bridge.dll"), "{error}"); + + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("目标不含 windows 时必须拒绝 prepared 产物"); + assert!(error.contains("未声明文件"), "{error}"); + } + + /// 随包库只归位声明里的文件:源码工作区的构建输入不进随包目录,随包目录多出或缺少文件都必须被拒绝。 + #[test] + fn staged_plugins_follow_declared_library_staging_files() { + let staging = PLUGINS + .library_staging + .iter() + .find(|entry| entry.layout == "godot-bundle") + .expect("godot staging"); + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + let mut relative_files = vec!["src/entry.mjs".to_string()]; + relative_files.extend( + staging + .files + .iter() + .map(|file| format!("{}/{}", staging.source_subdirectory, file)), + ); + let relative_files = relative_files + .iter() + .map(String::as_str) + .collect::>(); + write_plugin_copy( + &source_root, + &destination_root, + "agc-godot-editor", + &relative_files, + ); + let source_only = source_root.join("agc-godot-editor/native/gdextension/src/native.cpp"); + fs::create_dir_all(source_only.parent().expect("path parent")).expect("create source dir"); + fs::write(&source_only, "void build_input() {}\n").expect("write source-only file"); + + validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |name| name == "godot-editor-execute", + ) + .expect("声明文件齐备时随包库合法"); + + let stale = + destination_root.join("agc-godot-editor/native/gdextension/bin/win-x64/stale.dll"); + fs::write(&stale, "stale\n").expect("write stale library file"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |name| name == "godot-editor-execute", + ) + .expect_err("必须拒绝随包库里未声明的文件"); + assert!(error.contains("未声明文件"), "{error}"); + fs::remove_file(&stale).expect("remove stale library file"); + + let declared = destination_root + .join("agc-godot-editor") + .join(declared_relative_path(&format!( + "{}/{}", + staging.source_subdirectory, staging.files[0] + ))); + fs::remove_file(&declared).expect("remove declared library file"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |name| name == "godot-editor-execute", + ) + .expect_err("必须拒绝缺少声明文件的随包库"); + assert!(error.contains("缺少声明文件"), "{error}"); + + let error = validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |_| false, + ) + .expect_err("feature 关闭后必须拒绝随包库"); + assert!(error.contains("未声明文件"), "{error}"); + } + + #[test] + fn collect_sources_applies_declared_skip_rules() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path(); + fs::create_dir_all(root.join("target")).expect("create target"); + fs::create_dir_all(root.join("node_modules")).expect("create node_modules"); + fs::create_dir_all(root.join("src")).expect("create src"); + fs::write(root.join("target/junk.rs"), "junk\n").expect("write junk"); + fs::write(root.join("node_modules/pkg.js"), "pkg\n").expect("write pkg"); + fs::write(root.join("src/entry.mjs"), "entry\n").expect("write entry"); + fs::write(root.join("src/entry.test.mjs"), "test\n").expect("write test"); + fs::write(root.join(".env"), "secret\n").expect("write env"); + let files = collect_sources(root).expect("collect"); + assert_eq!(files.into_iter().collect::>(), ["src/entry.mjs"]); + } + + #[test] + fn declaration_pins_claude_agent_layout() { + assert_eq!(CLAUDE_AGENT_SDK_VERSION, "0.3.285"); + assert_eq!(CLAUDE_AGENT.version, "0.3.285"); + assert_eq!(CLAUDE_AGENT.resource_directory, "resources/claude-agent"); + assert_eq!(CLAUDE_AGENT.entry_file_name, "index.mjs"); + assert_eq!( + CLAUDE_AGENT.entry_relative_path, + "agent-sidecar/src/index.mjs" + ); + assert_eq!( + CLAUDE_AGENT.sdk_package_name, + "@anthropic-ai/claude-agent-sdk" + ); + assert_eq!(CLAUDE_AGENT.sdk_entry_file_name, "sdk.mjs"); + assert_eq!(CLAUDE_AGENT.targets.len(), 3); + let windows = claude_agent_target("x86_64-pc-windows-msvc").expect("windows sidecar"); + assert_eq!(windows.runtime_package, "claude-agent-sdk-win32-x64"); + assert_eq!(windows.runtime_file_name, "claude.exe"); + let mac = claude_agent_target("aarch64-apple-darwin").expect("mac sidecar"); + assert_eq!(mac.runtime_package, "claude-agent-sdk-darwin-arm64"); + assert_eq!(mac.runtime_file_name, "claude"); + assert!(claude_agent_target("x86_64-unknown-linux-gnu").is_none()); + } + + fn write_package_version(directory: &Path, metadata_file_name: &str, version: &str) { + fs::write( + directory.join(metadata_file_name), + serde_json::json!({ "version": version }).to_string(), + ) + .expect("write package metadata"); + } + + /// 写一份最小的 Claude Agent SDK sidecar 随包产物(入口 + SDK + 平台原生运行时)。 + fn write_claude_agent_bundle(target_dir: &Path, app_root: &Path, target: &str) { + let declared = claude_agent_target(target).expect("claude agent layout"); + let entry_source = app_root.join(declared_relative_path(CLAUDE_AGENT.entry_relative_path)); + fs::create_dir_all(entry_source.parent().expect("entry parent")).expect("create entry dir"); + fs::write(&entry_source, "console.log('sidecar');\n").expect("write entry source"); + fs::create_dir_all(target_dir).expect("create target dir"); + fs::write( + target_dir.join(CLAUDE_AGENT.entry_file_name), + "console.log('sidecar');\n", + ) + .expect("write staged entry"); + + let sdk = target_dir + .join(declared_relative_path(CLAUDE_AGENT.node_modules_directory)) + .join(declared_relative_path(CLAUDE_AGENT.sdk_package_name)); + fs::create_dir_all(&sdk).expect("create sdk package"); + fs::write( + sdk.join(CLAUDE_AGENT.sdk_entry_file_name), + "export const sdk = 1;\n", + ) + .expect("write sdk entry"); + write_package_version( + &sdk, + CLAUDE_AGENT.sdk_package_metadata_file_name, + CLAUDE_AGENT.version, + ); + + let runtime = target_dir + .join(declared_relative_path(CLAUDE_AGENT.node_modules_directory)) + .join(declared_relative_path(package_scope( + CLAUDE_AGENT.sdk_package_name, + ))) + .join(declared_relative_path(declared.runtime_package)); + fs::create_dir_all(&runtime).expect("create runtime package"); + let runtime_file = runtime.join(declared_relative_path(declared.runtime_file_name)); + fs::write(&runtime_file, "runtime\n").expect("write runtime"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&runtime_file, fs::Permissions::from_mode(0o755)) + .expect("chmod runtime"); + } + write_package_version( + &runtime, + CLAUDE_AGENT.sdk_package_metadata_file_name, + CLAUDE_AGENT.version, + ); + } + + #[test] + fn staged_claude_agent_bundle_is_checked_against_repository_sources() { + let temp = tempfile::tempdir().expect("tempdir"); + let target_dir = temp.path().join("resources/claude-agent"); + let app_root = temp.path().join("app"); + write_claude_agent_bundle(&target_dir, &app_root, "aarch64-apple-darwin"); + validate_staged_claude_agent(&target_dir, &app_root, "aarch64-apple-darwin") + .expect("合规目录通过"); + + fs::write( + target_dir.join(CLAUDE_AGENT.entry_file_name), + "console.log('drift');\n", + ) + .expect("write drifted entry"); + let error = validate_staged_claude_agent(&target_dir, &app_root, "aarch64-apple-darwin") + .expect_err("入口漂移必须被拒绝"); + assert!(error.contains("与仓库源码不一致"), "{error}"); + + write_claude_agent_bundle(&target_dir, &app_root, "aarch64-apple-darwin"); + fs::write(target_dir.join("stray.mjs"), "stray\n").expect("write stray"); + let error = validate_staged_claude_agent(&target_dir, &app_root, "aarch64-apple-darwin") + .expect_err("白名单外的文件必须被拒绝"); + assert!(error.contains("白名单外"), "{error}"); + } + + #[test] + fn staged_claude_agent_rejects_missing_sdk_entry_and_version_drift() { + let temp = tempfile::tempdir().expect("tempdir"); + let target_dir = temp.path().join("resources/claude-agent"); + let app_root = temp.path().join("app"); + write_claude_agent_bundle(&target_dir, &app_root, "x86_64-pc-windows-msvc"); + let sdk = target_dir + .join(declared_relative_path(CLAUDE_AGENT.node_modules_directory)) + .join(declared_relative_path(CLAUDE_AGENT.sdk_package_name)); + fs::remove_file(sdk.join(CLAUDE_AGENT.sdk_entry_file_name)).expect("remove sdk entry"); + let error = validate_staged_claude_agent(&target_dir, &app_root, "x86_64-pc-windows-msvc") + .expect_err("缺 SDK 入口必须被拒绝"); + assert!(error.contains("缺少 SDK 入口"), "{error}"); + + write_claude_agent_bundle(&target_dir, &app_root, "x86_64-pc-windows-msvc"); + write_package_version(&sdk, CLAUDE_AGENT.sdk_package_metadata_file_name, "0.0.0"); + let error = validate_staged_claude_agent(&target_dir, &app_root, "x86_64-pc-windows-msvc") + .expect_err("版本漂移必须被拒绝"); + assert!(error.contains("版本与声明不一致"), "{error}"); + } + + #[test] + fn claude_agent_layout_rejects_undeclared_target() { + let error = validate_staged_claude_agent( + Path::new("missing"), + Path::new("."), + "x86_64-unknown-linux-gnu", + ) + .expect_err("未声明目标必须被拒绝"); + assert!(error.contains("声明不含目标"), "{error}"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore b/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore deleted file mode 100644 index 6a7461313..000000000 --- a/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.dll diff --git a/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitkeep b/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 2e8e3ee12..4f6e8b58c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -30,11 +30,9 @@ mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; mod direct_tool_bridge; -mod direct_tool_calls; mod direct_tools_mcp; mod direct_turn_error; mod direct_turn_failure; -mod direct_turn_stream; mod direct_validation; mod generation; mod prompt; @@ -54,16 +52,19 @@ pub(crate) use claude_code_cli::{ direct_game_creator_claude_code_home_chat, game_creator_claude_code_cli_route_error, game_creator_claude_code_cli_version_identity, }; +pub(crate) use codex_app_server::direct_game_creator_codex_chat_at; use codex_app_server::*; +#[cfg(not(test))] pub(crate) use codex_app_server::{ - cancel_direct_codex_turn_at, direct_game_creator_codex_chat_at, - direct_game_creator_home_codex_chat, thread_id_for_project, DirectTurnCancelView, + cancel_direct_codex_turn_at, direct_game_creator_home_codex_chat, thread_id_for_project, + DirectTurnCancelView, }; use codex_cli::*; pub(crate) use codex_cli::{ game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity, }; pub(crate) use codex_provider_proxy::*; +#[cfg(not(test))] pub(crate) use design_runtime::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_user_item::*; @@ -71,11 +72,9 @@ pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_tool_bridge::*; -pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; pub(crate) use direct_turn_error::*; pub(crate) use direct_turn_failure::*; -pub(crate) use direct_turn_stream::*; pub(crate) use direct_validation::DirectValidationConfig; pub(crate) use generation::*; pub(crate) use prompt::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index 84d61771d..641ace7bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -318,7 +318,8 @@ pub(crate) fn game_creator_claude_code_cli_version_identity() -> Result>(); stream.push(String::new()); - parse_direct_stream_result( - stream.join("\n").as_bytes(), - client_turn_id.unwrap_or("claude-code-turn"), - observer, - ) + parse_direct_stream_result(stream.join("\n").as_bytes(), observer) } fn parse_direct_stream_result( stdout: &[u8], - turn_id: &str, mut observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, ) -> Result { let text = std::str::from_utf8(stdout) .map_err(|_| "Claude Agent SDK stream-json 不是 UTF-8".to_string())?; let mut result = None; - let mut tool_calls = std::collections::HashMap::::new(); for line in text.lines().filter(|line| !line.trim().is_empty()) { let event: serde_json::Value = serde_json::from_str(line) .map_err(|_| "Claude Agent SDK stream-json 包含无效 JSON".to_string())?; let event_type = event.get("type").and_then(serde_json::Value::as_str); if event_type == Some("assistant") { - let id = event - .get("uuid") - .and_then(serde_json::Value::as_str) - .unwrap_or("claude-code-assistant") - .to_string(); let content = event .pointer("/message/content") .and_then(serde_json::Value::as_array); @@ -810,77 +800,7 @@ fn parse_direct_stream_result( .collect::(); if !visible.is_empty() { if let Some(observer) = observer.as_deref_mut() { - observer(DirectCodexTurnObservation::AgentMessageSegment { - item_id: id, - accumulated_text: visible, - completed: true, - }); - } - } - for part in content.iter().filter(|part| { - part.get("type").and_then(serde_json::Value::as_str) == Some("tool_use") - }) { - let Some(call_id) = part.get("id").and_then(serde_json::Value::as_str) else { - continue; - }; - let name = part - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or("mcp_tool") - .to_string(); - let arguments = part - .get("input") - .cloned() - .unwrap_or(serde_json::Value::Null); - let now = direct_tool_call_now_ms(); - let call = DirectToolCall { - schema_version: DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(), - id: call_id.to_string(), - turn_id: turn_id.to_string(), - kind: "mcp_tool".to_string(), - title: "调用工具".to_string(), - summary: name, - status: "running".to_string(), - detail: DirectToolCallDetail { - command: serde_json::to_string(&arguments).ok(), - output: None, - changes: Vec::new(), - }, - started_at: now, - updated_at: now, - }; - if let Some(observer) = observer.as_deref_mut() { - observer(DirectCodexTurnObservation::ToolCall(call.clone())); - } - tool_calls.insert(call_id.to_string(), call); - } - } - } else if event_type == Some("user") { - if let Some(content) = event - .pointer("/message/content") - .and_then(serde_json::Value::as_array) - { - for part in content.iter().filter(|part| { - part.get("type").and_then(serde_json::Value::as_str) == Some("tool_result") - }) { - let Some(call_id) = part.get("tool_use_id").and_then(serde_json::Value::as_str) - else { - continue; - }; - let Some(mut call) = tool_calls.remove(call_id) else { - continue; - }; - call.status = "completed".to_string(); - call.updated_at = direct_tool_call_now_ms(); - call.detail.output = part - .get("content") - .and_then(serde_json::Value::as_str) - .map(str::to_string); - if part.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { - call.status = "failed".to_string(); - } - if let Some(observer) = observer.as_deref_mut() { - observer(DirectCodexTurnObservation::ToolCall(call)); + observer(DirectCodexTurnObservation::AgentMessageSegment(visible)); } } } @@ -963,14 +883,13 @@ mod tests { {"type":"result","is_error":false,"result":"最终回复"} "# .as_bytes(), - "turn-1", Some(&mut |event| observed.push(event)), ) .expect("stream result"); assert_eq!(text, "最终回复"); assert!(matches!( observed.as_slice(), - [DirectCodexTurnObservation::AgentMessageSegment { .. }] + [DirectCodexTurnObservation::AgentMessageSegment(_)] )); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs index 58eac5eeb..72b02c321 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs @@ -9,12 +9,21 @@ use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex, Weak}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; use std::time::Duration; use tokio::sync::{watch, Notify}; const MAX_PROTOCOL_ITEMS: usize = 2048; const MAX_REQUEST_CACHE: usize = 512; +/// 拒绝原因留痕的条数上限(按 `(method, reason)` 去重后的上限)。 +/// +/// 原因基本是固定的静态分类,上限只是防止宿主错误文本意外发散时无界增长。 +const MAX_DENIED_REASON_LOGS: usize = 64; +/// 未绑定执行器路径的进程级去重集合。 +/// +/// 这条路径没有适配器实例可挂去重状态(那正是它存在的前提),只能用进程级集合;上限与 +/// [`MAX_DENIED_REASON_LOGS`] 同口径。 +static NO_ADAPTER_INTERACTION_LOGGED: OnceLock>> = OnceLock::new(); /// 逐次审批协议的版本门禁:发行构建只接受捆绑侧车的固定版本;开发构建用宿主自带的 Codex /// (Linux 与未 stage 侧车时没有固定版本可用),按 profile 直接跳过该门禁。 @@ -37,10 +46,42 @@ pub(super) fn validate_approval_version(version: &str) -> Result<(), String> { } } +/// 未绑定宿主执行器时的交互兜底:一律拒绝,并把**真实结果形状**留痕。 +/// +/// 这是「回合没接单 / 适配器已释放」的唯一表现,不留痕线上就只剩一个没有原因的拒绝。 +/// 不同交互方法的拒绝形状并不相同(审批是 `decline`,权限申请是空授权,未知方法直接报错), +/// 所以日志按 `outcome` 分类,不能一律记成审批拒绝。 pub(super) fn denied_response(id: u64, method: &str) -> Value { + let outcome = match method { + "item/commandExecution/requestApproval" + | "item/fileChange/requestApproval" + | "mcpServer/elicitation/request" => "decline", + "item/permissions/requestApproval" => "empty-permissions", + _ => "unsupported-method", + }; + log_no_adapter_interaction_once(method, outcome, id); denied(id, method) } +/// 未绑定执行器时的交互兜底留痕:进程级有界去重。 +/// +/// 模型被拒后常会反复重试同一个动作,逐次记录会把其它诊断刷掉;同一 `(method, outcome)` 只写 +/// 第一行,`distinct_outcomes` 保留"一共出现过几种"的信息。 +fn log_no_adapter_interaction_once(method: &str, outcome: &str, request_id: u64) { + let logged = NO_ADAPTER_INTERACTION_LOGGED.get_or_init(|| Mutex::new(HashSet::new())); + let Ok(mut logged) = logged.lock() else { + return; + }; + if logged.len() >= MAX_DENIED_REASON_LOGS || !logged.insert(format!("{method}\u{1}{outcome}")) { + return; + } + let distinct = logged.len(); + drop(logged); + app_log!( + "agent.direct_codex.interaction.no_adapter method={method} outcome={outcome} request_id={request_id} distinct_outcomes={distinct}" + ); +} + pub(super) struct ExecutionBinding { inner: Weak, pub(super) adapter: Arc, @@ -169,6 +210,19 @@ struct Ticket { response: Option, } +/// 锁内解析一次审批 / 交互请求的结果。 +/// +/// 解析只做状态判定与登记;拒绝原因的留痕由调用方在**释放 `state` 之后**完成,避免持有审批 +/// 临界区做同步文件 I/O(见 `ExecutionAdapter::respond`)。 +enum ApprovalGateOutcome { + /// 已登记 ticket,进入租约申请与放行阶段。 + Proceed(Target), + /// 该 request id 已有结论,直接复用,不再申请租约。 + Cached(Value), + /// 拒绝,附留痕用的原因分类。 + Deny(&'static str), +} + #[derive(Default)] struct ProtocolState { turn_id: Option, @@ -195,6 +249,8 @@ pub(super) struct ExecutionAdapter { turn_failure: Mutex>, /// 用户/宿主是否主动要求终止这一轮(界面的「终止」按钮)。用户主动终止不是失败。 host_stop_requested: AtomicBool, + /// 已留痕的拒绝原因(`method\u{1}reason`)。同一回合内同因只记一次,避免模型重试刷屏。 + denied_reasons_logged: Mutex>, } fn identity(value: Option<&Value>) -> Option<&str> { @@ -248,6 +304,39 @@ fn accepted(id: u64, method: &str) -> Value { } } +/// app-server 交互回包未能送达的原因。 +/// +/// 两种失败共用同一条收束路径,但来源不同:写失败是 Codex 根本没收到 decision,回合绑定不一致 +/// 是回包已经产出、宿主无法确认执行作用域。留痕必须写真实原因,不能按函数名反推。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ResponseDeliveryFailure { + /// stdin 写入 / flush 失败。 + Write, + /// `bind_turn` 返回 false:回合绑定不一致。 + TurnBindMismatch, +} +impl ResponseDeliveryFailure { + fn as_str(self) -> &'static str { + match self { + Self::Write => "response-write-failed", + Self::TurnBindMismatch => "turn-bind-mismatch", + } + } + + /// 写进回合终态说明的收束原因。 + /// + /// 两种失败对用户的意义不同:写失败是回包真的没送出去;绑定不一致是回包已经产出,只是宿主 + /// 无法确认它属于哪一轮。不能共用「回包丢失」这一句,否则报告会把绑定问题说成丢包。 + fn interrupt_reason(self) -> &'static str { + match self { + Self::Write => "执行许可回包丢失,不能确认原操作是否已执行。", + Self::TurnBindMismatch => { + "回合绑定与当前 app-server 不一致,无法确认本轮操作的作用域;已停止本轮执行。" + } + } + } +} + impl ExecutionAdapter { pub(super) async fn for_turn( root: &Path, @@ -313,9 +402,52 @@ impl ExecutionAdapter { outcome, turn_failure: Mutex::new(None), host_stop_requested: AtomicBool::new(false), + denied_reasons_logged: Mutex::new(HashSet::new()), }) } + /// 审批 / 交互被拒的原因留痕。 + /// + /// 宿主拒绝 app-server 的请求时,线上只回一个 `decline`,用户和 Codex 都看不到是哪一道闸门 + /// 拦下的;原因只在这里落 AppData 日志。`reason` 只接受本模块的静态分类或宿主自己的错误 + /// 文本,绝不带请求参数、上游正文、路径或凭据。 + /// + /// 同一回合内 `(method, reason)` 只记一次:模型被拒后常反复重试同一动作,逐次记录会把真正 + /// 有用的诊断刷掉;"这一轮被拒过哪些原因"仍然完整。 + fn log_denied_reason(&self, method: &str, reason: &str) { + // 契约:留痕不带路径、上游正文或凭据。静态分类本来就没有;`lease-admit-failed` 这类透传的 + // 宿主错误文本可能内嵌绝对项目路径(如 `validate_patch_paths` 的路径校验错误),所以统一 + // 在唯一出口脱敏——去重键用脱敏后的文本,日志拷贝与 stderr 拷贝都不会带原文。 + let reason = crate::sanitize_diagnostic_message(reason, None); + let key = format!("{method}\u{1}{reason}"); + let thread_id = &self.thread_id; + self.log_once(key, |distinct| { + format!( + "agent.direct_codex.approval.denied thread_id={thread_id} method={method} reason={reason} distinct_reasons={distinct}" + ) + }); + } + + /// 有界去重留痕:同一回合内同 `key` 只写第一行。 + /// + /// 共用一把独立于审批状态的锁;锁不可用就放弃记录,留痕不得反过来影响放行判定。 + fn log_once(&self, key: String, build_line: impl FnOnce(usize) -> String) { + let Ok(mut logged) = self.denied_reasons_logged.lock() else { + return; + }; + if logged.len() >= MAX_DENIED_REASON_LOGS || !logged.insert(key) { + return; + } + let distinct = logged.len(); + drop(logged); + app_log!("{}", build_line(distinct)); + } + + fn denied(&self, id: u64, method: &str, reason: &str) -> Value { + self.log_denied_reason(method, reason); + denied(id, method) + } + pub(super) fn bind_turn(&self, turn_id: &str) -> bool { if turn_id.is_empty() || turn_id.len() > 256 { return false; @@ -452,114 +584,135 @@ impl ExecutionAdapter { self.spawn_settlement(entries); } + /// 锁内解析一次审批 / 交互请求:登记待放行的 ticket,或给出拒绝原因。 + /// + /// **只做状态判定与登记,不做 I/O、不写日志**:调用方必须在释放 `state` 之后再留痕。 + fn resolve_approval_gate( + &self, + state: &mut ProtocolState, + id: u64, + method: &str, + params: &Value, + fingerprint: &str, + ) -> ApprovalGateOutcome { + if !self.matches_scope(state, params) { + return ApprovalGateOutcome::Deny("request-out-of-turn-scope"); + } + if let Some(ticket) = state.tickets.get(&id) { + return if ticket.fingerprint == fingerprint { + match ticket.response.clone() { + Some(response) => ApprovalGateOutcome::Cached(response), + None => ApprovalGateOutcome::Deny("ticket-without-response"), + } + } else { + ApprovalGateOutcome::Deny("request-id-fingerprint-mismatch") + }; + } + // Approval tasks run concurrently; numeric request IDs need not enter + // this mutex in order. Retain seen IDs rather than rejecting by high-water. + if state.seen_request_ids.len() >= 8192 || !state.seen_request_ids.insert(id) { + return ApprovalGateOutcome::Deny("request-id-replayed-or-capacity"); + } + let target = match method { + "item/commandExecution/requestApproval" | "item/fileChange/requestApproval" => { + let Some(item_id) = identity(params.get("itemId")) else { + return ApprovalGateOutcome::Deny("approval-without-item-id"); + }; + let Some(item) = state.items.get(item_id) else { + return ApprovalGateOutcome::Deny("approval-for-unknown-item"); + }; + let expected = if method == "item/fileChange/requestApproval" { + ItemKind::Patch + } else { + ItemKind::Command + }; + if item.kind != expected || item.terminal.is_some() || item.lease.is_some() { + return ApprovalGateOutcome::Deny("item-not-pending-for-approval"); + } + if state.tickets.values().any(|ticket| matches!(&ticket.target, Target::Item(existing, _) if existing == item_id)) { + return ApprovalGateOutcome::Deny("item-approval-already-pending"); + } + Target::Item( + item_id.to_string(), + if expected == ItemKind::Patch { + EffectKind::Write + } else { + EffectKind::Execute + }, + ) + } + "mcpServer/elicitation/request" => { + if params + .pointer("/_meta/codex_approval_kind") + .and_then(Value::as_str) + != Some("mcp_tool_call") + || params.get("mode").and_then(Value::as_str) != Some("form") + { + return ApprovalGateOutcome::Deny("unsupported-mcp-elicitation-shape"); + } + let Some(server) = params.get("serverName").and_then(Value::as_str) else { + return ApprovalGateOutcome::Deny("mcp-elicitation-without-server-name"); + }; + if server == "agc_tools" || !self.third_party_servers.contains(server) { + return ApprovalGateOutcome::Deny("mcp-server-not-eligible-for-elicitation"); + } + let Some(arguments) = params.pointer("/_meta/tool_params") else { + return ApprovalGateOutcome::Deny("mcp-elicitation-without-tool-params"); + }; + let key = cohort_key(server, arguments); + let Some(cohort) = state.cohorts.get(&key) else { + return ApprovalGateOutcome::Deny("mcp-cohort-not-registered"); + }; + let pending_members = cohort + .members + .iter() + .filter(|member| { + state + .items + .get(*member) + .is_some_and(|entry| entry.terminal.is_none()) + }) + .count(); + let seen_tickets = state.tickets.values().filter(|ticket| matches!(&ticket.target, Target::Cohort(existing) if existing == &key)).count(); + if pending_members == 0 || cohort.members.len() <= seen_tickets { + return ApprovalGateOutcome::Deny("mcp-cohort-already-covered"); + } + state.cohorts.get_mut(&key).unwrap().admissions += 1; + Target::Cohort(key) + } + _ => return ApprovalGateOutcome::Deny("unsupported-approval-method"), + }; + if state.tickets.len() >= MAX_REQUEST_CACHE { + state.tickets.retain(|_, ticket| ticket.response.is_none()); + } + state.tickets.insert( + id, + Ticket { + fingerprint: fingerprint.to_string(), + target: target.clone(), + response: None, + }, + ); + ApprovalGateOutcome::Proceed(target) + } + pub(super) async fn respond(self: &Arc, id: u64, method: &str, params: &Value) -> Value { if self.closed.load(Ordering::Acquire) || self.is_host_ending() { - return denied(id, method); + return self.denied(id, method, "session-closed-or-host-ending"); } let fingerprint = cohort_key(method, params); - let target = { + // 解析与登记在锁内一次完成;`app_log!` 是同步文件 I/O(必要时还会轮转日志),所以拒绝 + // 留痕必须等 `state` 释放之后再做,否则会把审批临界区拉长到磁盘延迟。 + let outcome = { let Ok(mut state) = self.state.lock() else { - return denied(id, method); + return self.denied(id, method, "state-lock-poisoned"); }; - if !self.matches_scope(&state, params) { - return denied(id, method); - } - if let Some(ticket) = state.tickets.get(&id) { - return if ticket.fingerprint == fingerprint { - ticket - .response - .clone() - .unwrap_or_else(|| denied(id, method)) - } else { - denied(id, method) - }; - } - // Approval tasks run concurrently; numeric request IDs need not enter - // this mutex in order. Retain seen IDs rather than rejecting by high-water. - if state.seen_request_ids.len() >= 8192 || !state.seen_request_ids.insert(id) { - return denied(id, method); - } - let target = match method { - "item/commandExecution/requestApproval" | "item/fileChange/requestApproval" => { - let Some(item_id) = identity(params.get("itemId")) else { - return denied(id, method); - }; - let Some(item) = state.items.get(item_id) else { - return denied(id, method); - }; - let expected = if method == "item/fileChange/requestApproval" { - ItemKind::Patch - } else { - ItemKind::Command - }; - if item.kind != expected || item.terminal.is_some() || item.lease.is_some() { - return denied(id, method); - } - if state.tickets.values().any(|ticket| matches!(&ticket.target, Target::Item(existing, _) if existing == item_id)) { - return denied(id, method); - } - Target::Item( - item_id.to_string(), - if expected == ItemKind::Patch { - EffectKind::Write - } else { - EffectKind::Execute - }, - ) - } - "mcpServer/elicitation/request" => { - if params - .pointer("/_meta/codex_approval_kind") - .and_then(Value::as_str) - != Some("mcp_tool_call") - || params.get("mode").and_then(Value::as_str) != Some("form") - { - return denied(id, method); - } - let Some(server) = params.get("serverName").and_then(Value::as_str) else { - return denied(id, method); - }; - if server == "agc_tools" || !self.third_party_servers.contains(server) { - return denied(id, method); - } - let Some(arguments) = params.pointer("/_meta/tool_params") else { - return denied(id, method); - }; - let key = cohort_key(server, arguments); - let Some(cohort) = state.cohorts.get(&key) else { - return denied(id, method); - }; - let pending_members = cohort - .members - .iter() - .filter(|member| { - state - .items - .get(*member) - .is_some_and(|entry| entry.terminal.is_none()) - }) - .count(); - let seen_tickets = state.tickets.values().filter(|ticket| matches!(&ticket.target, Target::Cohort(existing) if existing == &key)).count(); - if pending_members == 0 || cohort.members.len() <= seen_tickets { - return denied(id, method); - } - state.cohorts.get_mut(&key).unwrap().admissions += 1; - Target::Cohort(key) - } - _ => return denied(id, method), - }; - if state.tickets.len() >= MAX_REQUEST_CACHE { - state.tickets.retain(|_, ticket| ticket.response.is_none()); - } - state.tickets.insert( - id, - Ticket { - fingerprint, - target: target.clone(), - response: None, - }, - ); - target + self.resolve_approval_gate(&mut state, id, method, params, &fingerprint) + }; + let target = match outcome { + ApprovalGateOutcome::Proceed(target) => target, + ApprovalGateOutcome::Cached(response) => return response, + ApprovalGateOutcome::Deny(reason) => return self.denied(id, method, reason), }; self.settling.fetch_add(1, Ordering::AcqRel); let root = self.root.clone(); @@ -592,13 +745,17 @@ impl ExecutionAdapter { }) .await .unwrap_or_else(|_| Err("执行许可任务中断".into())); + // 闸门拒绝(合同缺失 / 预算耗尽 / 阶段已收束 / 同一输入重复受理…)的真实原因只在 + // `admitted` 里;它随后会被 move 掉,先取副本,供最后统一留痕。 + let admit_failure = admitted.as_ref().err().cloned(); let mut allowed = false; let mut rejected_lease = None; + let mut denied_reason = None; let entries = { let Ok(mut state) = self.state.lock() else { self.settling.fetch_sub(1, Ordering::AcqRel); self.changed.notify_waiters(); - return denied(id, method); + return self.denied(id, method, "state-lock-poisoned-after-admit"); }; if let Target::Cohort(key) = &target { if let Some(cohort) = state.cohorts.get_mut(key) { @@ -617,6 +774,7 @@ impl ExecutionAdapter { item.lease = Some(entry); allowed = true; } else { + denied_reason = Some("item-left-pending-before-lease-attach"); rejected_lease = Some(entry); } } @@ -625,11 +783,13 @@ impl ExecutionAdapter { cohort.leases.push(entry); allowed = true; } else { + denied_reason = Some("cohort-gone-before-lease-attach"); rejected_lease = Some(entry); } } } } else { + denied_reason = Some("session-closed-before-lease-attach"); rejected_lease = Some(entry); } } @@ -652,7 +812,13 @@ impl ExecutionAdapter { if allowed { accepted(id, method) } else { - denied(id, method) + // 每个请求只留一条:admit 失败优先用宿主的真实错误文本,其次是租约附着阶段的分类。 + let reason = match (admit_failure, denied_reason) { + (Some(error), _) => format!("lease-admit-failed: {error}"), + (None, Some(reason)) => reason.to_string(), + (None, None) => "lease-not-granted".to_string(), + }; + self.denied(id, method, &reason) } } @@ -694,12 +860,22 @@ impl ExecutionAdapter { .await; } - pub(super) fn response_write_failed(self: &Arc) { + pub(super) fn response_delivery_failed(self: &Arc, cause: ResponseDeliveryFailure) { + // 回包没送达 Codex 时线上表现为「审批没有回应」;与主动 decline 分开留痕。 + // 同一回合内同一原因只留一行:这一步可能被重复触发。 + self.log_once( + format!("host-interaction\u{1}response-delivery:{}", cause.as_str()), + |_| { + format!( + "agent.direct_codex.approval.response_delivery_failed thread_id={} cause={}", + self.thread_id, + cause.as_str() + ) + }, + ); let adapter = Arc::clone(self); tokio::spawn(async move { - adapter - .interrupt("执行许可回包丢失,不能确认原操作是否已执行。") - .await; + adapter.interrupt(cause.interrupt_reason()).await; }); } @@ -1689,4 +1865,58 @@ mod tests { ); assert!(denied(5, "item/tool/call").get("error").is_some()); } + + #[tokio::test] + async fn denied_approval_records_its_reason_once_per_turn() { + let (_temp, adapter) = fixture(); + let method = "item/commandExecution/requestApproval"; + assert_eq!( + adapter.respond(1, method, &approval("missing")).await["result"]["decision"], + "decline" + ); + // 线上只回 decline;原因必须留在适配器上,供 AppData 日志回查是哪一道闸门拦下的。 + let key = format!("{method}\u{1}approval-for-unknown-item"); + assert!(adapter.denied_reasons_logged.lock().unwrap().contains(&key)); + // 模型重试同一动作会换新的 request id,但同一回合同因只留一条。 + assert_eq!( + adapter.respond(2, method, &approval("missing")).await["result"]["decision"], + "decline" + ); + assert_eq!(adapter.denied_reasons_logged.lock().unwrap().len(), 1); + } + + #[test] + fn denied_reason_logging_is_bounded() { + let (_temp, adapter) = fixture(); + for index in 0..(MAX_DENIED_REASON_LOGS + 8) { + adapter.log_denied_reason("unsupported-approval-method", &format!("reason-{index}")); + } + assert_eq!( + adapter.denied_reasons_logged.lock().unwrap().len(), + MAX_DENIED_REASON_LOGS + ); + } + + #[test] + fn denied_reason_logging_scrubs_embedded_paths() { + let (_temp, adapter) = fixture(); + adapter.log_denied_reason( + "item/commandExecution/requestApproval", + "lease-admit-failed: 读取路径失败:/home/someone/secret-project/app.ts", + ); + let logged = adapter.denied_reasons_logged.lock().unwrap(); + let key = logged.iter().next().expect("拒绝原因应留痕"); + assert!(key.contains("lease-admit-failed"), "{key}"); + assert!(!key.contains("/home/someone"), "留痕不得带绝对路径:{key}"); + } + + #[test] + fn response_delivery_failure_texts_match_their_cause() { + assert!(ResponseDeliveryFailure::Write + .interrupt_reason() + .contains("回包丢失")); + let bind = ResponseDeliveryFailure::TurnBindMismatch.interrupt_reason(); + assert!(bind.contains("绑定"), "{bind}"); + assert!(!bind.contains("回包丢失"), "绑定失败不是回包丢失:{bind}"); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 3c00d1410..0b0cbb827 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -18,7 +18,7 @@ mod execution; mod model_catalog; pub(crate) use direct_project_identity::thread_id_for_project; use direct_project_identity::*; -use execution::ExecutionAdapter; +use execution::{ExecutionAdapter, ResponseDeliveryFailure}; const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; @@ -642,21 +642,12 @@ enum CodexTurnEvent { #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum DirectCodexTurnObservation { AccumulatedText(String), - /// 一个 assistant 文本段的当前累计全文。 - /// - /// `item_id` 是一次 assistant 消息的稳定身份:同一个 id 的后续 delta 属于**同一段**, - /// id 变了就是新的一段。回合流的"文本段 + 工具"顺序用它来分段,而不是按 delta 分。 - AgentMessageSegment { - item_id: String, - accumulated_text: String, - completed: bool, - }, + /// 一段可见的 assistant 正文(同一 assistant item 的当前累计全文)。 + AgentMessageSegment(String), IntermediateText(String), /// 模型的思考过程(reasoning item 的明文摘要):流式阶段整段替换下发。 Reasoning(String), Activity(&'static str), - /// 一条结构化工具调用(`item/started` 与 `item/completed` 各采一次,按 id 幂等)。 - ToolCall(crate::DirectToolCall), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -809,7 +800,7 @@ fn direct_thread_event_item( root: &std::path::Path, item: &serde_json::Value, ) -> Option { - thread_item_from_value(root, item, direct_tool_call_now_ms()) + thread_item_from_value(root, item, direct_now_ms()) } /// 运行态条目投影:Codex 回显的用户消息整条跳过。 @@ -2553,34 +2544,15 @@ impl CodexAppServerConnection { Ok(connection) } - #[cfg(test)] - async fn spawn( - llm: &GameCreatorLlmConfig, - credential: &CodexAppServerCredential, - ) -> Result { - let executable = game_creator_codex_cli_executable_path() - .map_err(platform_llm::LlmError::InvalidConfig)?; - Self::spawn_with_executable_and_credential(llm, credential, executable.as_os_str()).await - } - - #[cfg(test)] + #[cfg(all(test, unix))] async fn spawn_with_executable( llm: &GameCreatorLlmConfig, executable: &std::ffi::OsStr, ) -> Result { let credential = resolve_game_creator_codex_app_server_credential(llm)?; - Self::spawn_with_executable_and_credential(llm, &credential, executable).await - } - - #[cfg(test)] - async fn spawn_with_executable_and_credential( - llm: &GameCreatorLlmConfig, - credential: &CodexAppServerCredential, - executable: &std::ffi::OsStr, - ) -> Result { Self::spawn_with_executable_and_credential_at_workspace( llm, - credential, + &credential, executable, None, CodexAppServerWorkspaceMode::ToolHost, @@ -2588,7 +2560,7 @@ impl CodexAppServerConnection { .await } - #[cfg(test)] + #[cfg(all(test, unix))] async fn spawn_with_executable_and_credential_at_workspace( llm: &GameCreatorLlmConfig, credential: &CodexAppServerCredential, @@ -3344,9 +3316,9 @@ impl CodexAppServerConnection { ) -> Result { let _turn_guard = self.inner.turn_gate.lock().await; let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); - // 工具调用卡片的 turnId 用 AGC 客户端回合 id(与实时事件、落盘条目同一口径), - // 不用 Codex app-server 自己的 turnId——前端要按它把卡片挂回对应的那一轮。 - let direct_tool_call_turn_id: Option = direct_client_turn_id + // 回合身份用 AGC 客户端回合 id(与实时事件、落盘条目同一口径), + // 不用 Codex app-server 自己的 turnId。 + let direct_turn_id: Option = direct_client_turn_id .map(str::trim) .filter(|turn_id| !turn_id.is_empty()) .map(str::to_string); @@ -3525,7 +3497,7 @@ impl CodexAppServerConnection { } let context = ProjectModelUsageContext { root: history_root.to_path_buf(), - client_turn_id: direct_tool_call_turn_id.clone(), + client_turn_id: direct_turn_id.clone(), thread_id: Some(thread_id.clone()), requested_model: model.to_string(), }; @@ -3546,7 +3518,7 @@ impl CodexAppServerConnection { let turn_start_cancellation = Arc::new(CodexTurnStartCancellation::new(&self.inner, &thread_id)); // Direct 回合登记为"可终止":终止命令只作用在这一轮上,回合结束时自动注销。 - let _active_turn_guard = direct_tool_call_turn_id + let _active_turn_guard = direct_turn_id .as_deref() .filter(|_| self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject) .map(|turn_id| { @@ -3630,7 +3602,7 @@ impl CodexAppServerConnection { // `startedAt` / `completedAt` 只有秒级,秒级截断撑不起前端 0.1 秒粒度的展示,也可能 // 让完成时刻落进该轮用户消息的同一秒。因此这里在进入模型往返前取一次宿主毫秒钟,与 // `durationMs` 相加得到终态时刻;拿不到 `durationMs` 时退回观察时刻。 - let direct_turn_started_at_ms = direct_tool_call_now_ms(); + let direct_turn_started_at_ms = direct_now_ms(); let mut receiver = self.register_turn(&turn_id).await; let mut direct_project_history = DirectProjectHistoryAccumulator::default(); let mut guard = CodexTurnGuard { @@ -3730,17 +3702,14 @@ impl CodexAppServerConnection { observer(DirectCodexTurnObservation::AccumulatedText( streamed_text.clone(), )); - // 同一个 assistant item 的当前累计全文:回合流按 item 分段, - // 段内只追加、段间才换行,不能拿"整轮累计"当一段。 + // 同一 assistant item 的当前累计全文,不能拿"整轮累计"当一段。 let segment_text = direct_project_history .accumulated_text_for(&item_id) .unwrap_or_else(|| delta.clone()); if !segment_text.trim().is_empty() { - observer(DirectCodexTurnObservation::AgentMessageSegment { - item_id: item_id.clone(), - accumulated_text: segment_text, - completed: false, - }); + observer(DirectCodexTurnObservation::AgentMessageSegment( + segment_text, + )); } } if let Some(callback) = on_agent_message_delta.as_deref_mut() { @@ -3799,10 +3768,7 @@ impl CodexAppServerConnection { // 通知的钟就是该阶段唯一可证明的时间。 append_thread_event( &direct_thread_id, - ThreadEvent::item_completed( - entry_item, - direct_tool_call_now_ms(), - ), + ThreadEvent::item_completed(entry_item, direct_now_ms()), ); } } @@ -3860,43 +3826,22 @@ impl CodexAppServerConnection { completed, ¶ms, ); - // 工具调用卡片:item/started 与 item/completed 各采一次, - // 由下游按 id 幂等 upsert 成同一条。采集失败(拿不到 id / - // 非工具类 item)就静默跳过,不影响这一轮的其它投影。 - if let Some(turn_id) = direct_tool_call_turn_id.as_deref() { - if let Some(tool_call) = direct_tool_call_from_item( - history_root, - item, - turn_id, - completed, - direct_tool_call_now_ms(), - ) { - if let Some(observer) = direct_observer.as_deref_mut() { - observer(DirectCodexTurnObservation::ToolCall( - tool_call, - )); - } - } - } } if item_type == "agentMessage" { // 某些 app-server 实现会在工具开始后停止发送 agentMessage delta, - // 但会在 item/completed 携带完整文本。把这份最终快照补进回合流, - // 让流中的文本段不会停在工具前的短前缀。 + // 但会在 item/completed 携带完整文本;把这份最终快照也交给观察者, + // 运行态条目就不会停在工具前的短前缀。 if completed { - if let (Some(item_id), Some(text)) = ( - item.get("id").and_then(serde_json::Value::as_str), - item.get("text") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()), - ) { + if let Some(text) = item + .get("text") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + { if let Some(observer) = direct_observer.as_deref_mut() { observer( - DirectCodexTurnObservation::AgentMessageSegment { - item_id: item_id.to_string(), - accumulated_text: text.to_string(), - completed: true, - }, + DirectCodexTurnObservation::AgentMessageSegment( + text.to_string(), + ), ); } } @@ -3938,7 +3883,7 @@ impl CodexAppServerConnection { ¶ms, item, false, - direct_tool_call_now_ms(), + direct_now_ms(), ), ), ); @@ -3962,15 +3907,10 @@ impl CodexAppServerConnection { .filter(|text| !text.trim().is_empty()) { final_text = Some(text.to_string()); - if let (Some(item_id), Some(observer)) = ( - item.get("id").and_then(serde_json::Value::as_str), - direct_observer.as_deref_mut(), - ) { - observer(DirectCodexTurnObservation::AgentMessageSegment { - item_id: item_id.to_string(), - accumulated_text: text.to_string(), - completed: true, - }); + if let Some(observer) = direct_observer.as_deref_mut() { + observer(DirectCodexTurnObservation::AgentMessageSegment( + text.to_string(), + )); } } } @@ -3987,7 +3927,7 @@ impl CodexAppServerConnection { thread_turn_completed_at_ms( turn, Some(direct_turn_started_at_ms), - direct_tool_call_now_ms(), + direct_now_ms(), ), )); } @@ -4157,12 +4097,12 @@ impl CodexAppServerConnection { .unwrap_or_else(|| fallback_status.to_string()); // 有执行许可时,起止时间包含实际宿主收尾;上游模型完成不能提前结束 UI。 let completed_at = if approval_adapter.is_some() { - direct_tool_call_now_ms() + direct_now_ms() } else { model_terminal .as_ref() .map(|(_, at)| *at) - .unwrap_or_else(direct_tool_call_now_ms) + .unwrap_or_else(direct_now_ms) }; // 终态判定的**事实**在这里固定,写点留到整轮真正结束之后(见下面的 // `turn_result`):终态只有 `turn.completed` 一种事件,失败时同一个事件带 `failure` @@ -4884,7 +4824,7 @@ async fn read_game_creator_codex_app_server_stdout( }; if !sent { if let Some(adapter) = adapter { - adapter.response_write_failed(); + adapter.response_delivery_failed(ResponseDeliveryFailure::Write); } } }); @@ -4916,7 +4856,9 @@ async fn read_game_creator_codex_app_server_stdout( let adapter = inner.execution.lock().ok().and_then(|slot| slot.clone()); if let Some(adapter) = adapter { if !adapter.bind_turn(turn_id) { - adapter.response_write_failed(); + adapter.response_delivery_failed( + ResponseDeliveryFailure::TurnBindMismatch, + ); } } } @@ -5464,6 +5406,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( /// fresh isolated read-only workspace and a stable in-process thread so a /// normal conversation can continue without creating a project, assets, a /// preview, or a version record. +#[cfg(not(test))] pub(crate) async fn direct_game_creator_home_codex_chat( system_prompt: String, user_prompt: String, @@ -5675,6 +5618,104 @@ mod tests { assert!(table.select(&key, None).is_err()); } + fn exited_direct_turn_cancellation() -> Arc { + Arc::new(CodexTurnStartCancellation { + inner: Weak::new(), + thread_id: String::new(), + turn_id: std::sync::Mutex::new(None), + cancelled: AtomicBool::new(false), + interrupt_sent: AtomicBool::new(false), + execution: None, + }) + } + + #[test] + fn cancel_direct_codex_turn_rejects_another_turn_without_releasing_it() { + let root = tempfile::tempdir().expect("direct turn project"); + let thread_id = thread_id_for_project(root.path()); + let invocation = TurnReservation::accept_for_test(&thread_id, "client-turn-current"); + let cancellation = exited_direct_turn_cancellation(); + let key = direct_codex_active_turn_key(root.path()); + let _registered = register_active_direct_codex_turn( + key.clone(), + "client-turn-current", + Arc::clone(&cancellation), + ); + let subscription = crate::agent::subscribe_thread(&thread_id); + let _ = crate::agent::consume_thread(&subscription.subscription_id) + .expect("drain turn start event"); + + let error = cancel_direct_codex_turn_at(root.path(), Some("client-turn-other")) + .expect_err("another turn must not be interrupted or released"); + assert_eq!(error, DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE); + assert!(!cancellation.cancelled.load(Ordering::Acquire)); + assert_eq!( + direct_codex_active_turns() + .lock() + .expect("active turn table") + .registered_client_turn_id(&key), + Some("client-turn-current") + ); + assert_eq!( + crate::agent::read_turn_identity(&thread_id) + .expect("original turn remains active") + .client_turn_id, + "client-turn-current" + ); + assert!( + crate::agent::consume_thread(&subscription.subscription_id) + .expect("consume turn events") + .events + .is_empty(), + "wrong-turn rejection must not publish a terminal event" + ); + drop(invocation); + } + + #[test] + fn cancel_direct_codex_turn_releases_exited_executor_and_completes_original_turn() { + let root = tempfile::tempdir().expect("direct turn project"); + let thread_id = thread_id_for_project(root.path()); + let invocation = TurnReservation::accept_for_test(&thread_id, "client-turn-exited"); + let _registered = register_active_direct_codex_turn( + direct_codex_active_turn_key(root.path()), + "client-turn-exited", + exited_direct_turn_cancellation(), + ); + let subscription = crate::agent::subscribe_thread(&thread_id); + let _ = crate::agent::consume_thread(&subscription.subscription_id) + .expect("drain turn start event"); + + let view = cancel_direct_codex_turn_at(root.path(), Some("client-turn-exited")) + .expect("exited executor releases the residual invocation"); + assert_eq!(view.outcome, DIRECT_TURN_CANCEL_OUTCOME_RELEASED); + assert_eq!(view.client_turn_id, "client-turn-exited"); + assert!(view.message.contains("执行进程已退出")); + assert_eq!(crate::agent::read_turn_identity(&thread_id), None); + let events = crate::agent::consume_thread(&subscription.subscription_id) + .expect("consume terminal event") + .events; + assert!(matches!( + events.as_slice(), + [ThreadEvent::TurnCompleted { + status, + failure: None, + at: Some(_), + user_item_id: Some(user_item_id), + }] if status == "aborted" && user_item_id == "direct-codex:client-turn-exited:user" + )); + + let next = TurnReservation::accept_for_test(&thread_id, "client-turn-next"); + drop(invocation); + assert_eq!( + crate::agent::read_turn_identity(&thread_id) + .expect("old guard must not clear next turn") + .client_turn_id, + "client-turn-next" + ); + drop(next); + } + /// 终止路径会从同步命令线程和 `Drop` 里派发 app-server 任务:那些线程没有 tokio /// runtime 上下文。`tokio::spawn` 在那里 panic,panic 跨不过 IPC 回调边界就把整个 /// 进程 abort(0xC0000409,"点终止就闪退")。这条用例把派发入口钉在没有 runtime @@ -7574,6 +7615,8 @@ while IFS= read -r line; do :; done .map(|argument| argument.to_string_lossy().into_owned()) .collect::>() .join("\n"); + assert!(home_arguments.contains("--disable\nshell_tool")); + assert!(home_arguments.contains("--disable\nunified_exec")); } #[cfg(windows)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index 918e1e6df..707e5e29a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -8,11 +8,6 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; #[path = "../../build_support/codex_bundle.rs"] pub(crate) mod codex_bundle; -// 复用构建端校验的既有单测,生产运行时只编译共享布局。 -#[cfg(test)] -#[path = "../../build_support/codex_package_metadata.rs"] -mod codex_package_metadata; - const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex"; const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 71a086fc6..9cbbf7f80 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -8,6 +8,7 @@ use std::fs::File; use std::path::{Path, PathBuf}; use std::sync::OnceLock; use std::time::{Duration, Instant}; +#[cfg(not(test))] use tauri::Emitter; use uuid::Uuid; @@ -1398,12 +1399,23 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> { Ok(()) } +#[cfg(not(test))] #[tauri::command] pub(crate) async fn hydrate_design_agent_session( app: tauri::AppHandle, project_path: String, ) -> Result, String> { let root = Path::new(project_path.trim()); + hydrate_design_agent_session_at(root, move || { + DesignResources::new(resolve_design_resources_root(&app)?) + }) + .await +} + +async fn hydrate_design_agent_session_at( + root: &Path, + load_resources: impl FnOnce() -> Result + Send, +) -> Result, String> { enforce_project_permission_policy(root, "conversation.read")?; if read_design_runtime_mode(root)? .as_ref() @@ -1432,7 +1444,7 @@ pub(crate) async fn hydrate_design_agent_session( return Ok(Some(design_view(&session, true))); }; if design_session_has_uncertain_batch(&session) { - let resources = DesignResources::new(resolve_design_resources_root(&app)?)?; + let resources = load_resources()?; let view = recover_uncertain_design_batch(root, &resources, session, active).await?; return Ok(Some(view)); } @@ -1476,9 +1488,14 @@ fn quarantine_corrupted_design_session(root: &Path) -> Result<(), String> { std::fs::rename(&path, &backup).map_err(|error| format!("隔离损坏的策划会话失败:{error}")) } +#[cfg(not(test))] #[tauri::command] pub(crate) fn reset_design_agent_session(project_path: String) -> Result<(), String> { let root = Path::new(project_path.trim()); + reset_design_agent_session_at(root) +} + +fn reset_design_agent_session_at(root: &Path) -> Result<(), String> { enforce_project_permission_policy(root, "conversation.write")?; design_project_id(root)?; let path = crate::agent::runtime_protocol::design_session_path(root); @@ -1488,6 +1505,7 @@ pub(crate) fn reset_design_agent_session(project_path: String) -> Result<(), Str Ok(()) } +#[cfg(not(test))] #[tauri::command] pub(crate) fn get_design_agent_runtime_mode( project_path: String, @@ -1515,8 +1533,13 @@ fn design_debug_enabled() -> bool { == Some("1") } +#[cfg(not(test))] #[tauri::command] pub(crate) fn is_design_agent_debug_enabled() -> bool { + design_debug_command_enabled() +} + +fn design_debug_command_enabled() -> bool { cfg!(debug_assertions) && design_debug_enabled() } @@ -1540,16 +1563,27 @@ pub(crate) fn set_design_agent_runtime_mode( write_design_runtime_mode(root, active_runtime.trim()) } +#[cfg(not(test))] #[tauri::command] pub(crate) fn debug_fast_forward_design_session( app: tauri::AppHandle, project_path: String, target_phase: String, ) -> Result { - if !is_design_agent_debug_enabled() { + debug_fast_forward_design_session_at(Path::new(project_path.trim()), &target_phase, |event| { + app.emit("design-agent-update", event) + .map_err(|error| format!("刷新策划调试状态失败:{error}")) + }) +} + +fn debug_fast_forward_design_session_at( + root: &Path, + target_phase: &str, + emit: impl FnOnce(DesignEvent) -> Result<(), String>, +) -> Result { + if !design_debug_command_enabled() { return Err("策划 Agent 快速推进仅可用于 Debug 构建".to_string()); } - let root = Path::new(project_path.trim()); let project_id = design_project_id(root)?; let target_index = design_phase_index(target_phase.trim())?; let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( @@ -1597,21 +1631,18 @@ pub(crate) fn debug_fast_forward_design_session( session.updated_at = unix_timestamp(); write_design_session(root, &session)?; let mode = write_design_runtime_mode(root, "design")?; - app.emit( - "design-agent-update", - design_event( - root, - "debug-fast-forward", - "state", - None, - None, - Some(design_view(&session, false)), - ), - ) - .map_err(|error| format!("刷新策划调试状态失败:{error}"))?; + emit(design_event( + root, + "debug-fast-forward", + "state", + None, + None, + Some(design_view(&session, false)), + ))?; Ok(mode) } +#[cfg(not(test))] #[tauri::command] pub(crate) async fn continue_design_agent_session( app: tauri::AppHandle, @@ -1636,6 +1667,7 @@ pub(crate) async fn continue_design_agent_session( .await } +#[cfg(not(test))] #[tauri::command] pub(crate) async fn decide_design_phase( app: tauri::AppHandle, @@ -1662,6 +1694,7 @@ pub(crate) async fn decide_design_phase( .await } +#[cfg(not(test))] #[tauri::command] pub(crate) fn list_design_workspace( project_path: String, @@ -1672,6 +1705,7 @@ pub(crate) fn list_design_workspace( list_design_workspace_files(root) } +#[cfg(not(test))] #[tauri::command] pub(crate) fn read_design_workspace_file( project_path: String, @@ -1685,7 +1719,6 @@ pub(crate) fn read_design_workspace_file( #[cfg(test)] mod fake_provider { - use super::*; use std::cell::{Cell, RefCell}; use std::collections::VecDeque; @@ -3236,6 +3269,105 @@ mod tests { assert!(next.session.last_error.is_none()); } + #[tokio::test(flavor = "current_thread")] + async fn hydrate_quarantines_a_corrupted_session_without_loading_resources() { + let (_temp, root, _resources) = init_design_project(); + let path = crate::agent::runtime_protocol::design_session_path(&root); + fs::create_dir_all(path.parent().expect("session directory")).expect("create session dir"); + fs::write(&path, b"{broken").expect("write malformed session"); + + let view = hydrate_design_agent_session_at(&root, || -> Result { + panic!("corrupted session must not load resources") + }) + .await + .expect("quarantine malformed session"); + assert!(view.is_none()); + assert!(!path.exists()); + let backups = fs::read_dir(path.parent().expect("session directory")) + .expect("list session directory") + .map(|entry| entry.expect("session entry").path()) + .filter(|entry| { + entry + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("session.corrupted.")) + }) + .collect::>(); + assert_eq!(backups.len(), 1); + assert_eq!( + fs::read(&backups[0]).expect("read quarantined session"), + b"{broken" + ); + } + + #[test] + fn reset_archives_the_existing_design_session() { + let (_temp, root, _resources) = init_design_project(); + let path = crate::agent::runtime_protocol::design_session_path(&root); + write_design_session(&root, &new_design_session("design-fake", "")) + .expect("write design session"); + + reset_design_agent_session_at(&root).expect("reset design session"); + assert!(!path.exists()); + let backups = fs::read_dir(path.parent().expect("session directory")) + .expect("list session directory") + .map(|entry| entry.expect("session entry").path()) + .filter(|entry| { + entry + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("session.corrupted.")) + }) + .collect::>(); + assert_eq!(backups.len(), 1); + reset_design_agent_session_at(&root).expect("repeat reset without active session"); + } + + #[cfg(debug_assertions)] + #[test] + fn debug_fast_forward_checks_the_gate_and_persists_before_event_delivery() { + let (_temp, root, _resources) = init_design_project(); + let _debug_env = enable_design_debug_for_test(); + std::env::remove_var("GENARRATIVE_AGC_DESIGN_DEBUG"); + let error = debug_fast_forward_design_session_at(&root, "architecture", |_| { + panic!("debug gate must reject before emitting") + }) + .expect_err("debug gate should be closed"); + assert!(error.contains("Debug 构建")); + assert!(read_design_session(&root).expect("read session").is_none()); + + std::env::set_var("GENARRATIVE_AGC_DESIGN_DEBUG", "1"); + let existing = root.join("design_artifacts/project/00_concept/design.md"); + fs::create_dir_all(existing.parent().expect("artifact directory")) + .expect("create artifact directory"); + fs::write(&existing, "已有真实内容").expect("write existing artifact"); + let error = debug_fast_forward_design_session_at(&root, "architecture", |event| { + assert_eq!(event.kind, "state"); + assert_eq!(event.client_turn_id, "debug-fast-forward"); + Err("event delivery failed".to_string()) + }) + .expect_err("event failure should be reported"); + assert_eq!(error, "event delivery failed"); + assert_eq!( + fs::read_to_string(existing).expect("read existing artifact"), + "已有真实内容" + ); + assert!(root + .join("design_artifacts/project/02_architecture/design.md") + .is_file()); + let session = read_design_session(&root) + .expect("read fast-forwarded session") + .expect("fast-forwarded session"); + assert_eq!(session.current_phase, "architecture"); + assert_eq!(session.approved_phases, vec!["concept", "top_design"]); + assert!(session.pending_batch.is_none() && session.turn.is_none()); + assert_eq!( + read_design_runtime_mode(&root) + .expect("read runtime mode") + .expect("runtime mode") + .active_runtime, + "design" + ); + } + #[tokio::test(flavor = "current_thread")] async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() { let (_temp, root, resources) = init_design_project(); @@ -3291,16 +3423,10 @@ mod tests { ))], 0, ); - let view = recover_uncertain_design_batch(&root, &resources, session, { - try_open_game_creator_agent_runtime_task_lock_file( - &root, - ".agent/design-agent/active.lock", - ) - .expect("open active lock") - .expect("active lock is free") - }) - .await - .expect("recover uncertain batch"); + let view = hydrate_design_agent_session_at(&root, move || Ok(resources)) + .await + .expect("hydrate uncertain batch") + .expect("restored session"); assert!(!view.running); assert!(view.session.last_error.is_none()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index cc0d36ddc..addc431cf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -6,6 +6,7 @@ use std::collections::BTreeMap; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +#[cfg(not(test))] use tauri::Manager; const DESIGN_WORKSPACE_ROOT: &str = "design_artifacts"; @@ -168,6 +169,7 @@ impl DesignResources { } } +#[cfg(not(test))] pub(crate) fn resolve_design_resources_root(app: &tauri::AppHandle) -> Result { let mut candidates = vec![PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent")]; if let Ok(dir) = app.path().resource_dir() { @@ -539,6 +541,7 @@ pub(crate) fn read_design_workspace_file_at(root: &Path, path: &str) -> Result Result, String> { Ok(read_direct_project_history_items_slice_at(root, DirectProjectHistoryAnchor::Newest, 1)?.3) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 42af654b8..06b012c94 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -1,11 +1,10 @@ use super::*; use base64::Engine as _; -use std::collections::BTreeMap; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::Mutex; mod user_input; +#[cfg(not(test))] pub(crate) use user_input::enqueue_direct_codex_turn; #[cfg(test)] pub(crate) use user_input::normalize_direct_client_turn_id; @@ -474,10 +473,6 @@ pub(crate) fn active_turn_id_at(root: &Path) -> Result { /// 这期间执行器侧还没有这一轮的登记,但这一轮马上就会进去,提前释放等于放开并发。 const STALE_TURN_RELEASE_MIN_AGE_MS: u64 = 60_000; -fn direct_taonier_active_now_millis() -> u64 { - unix_millis().min(u128::from(u64::MAX)) as u64 -} - /// "终止"拿不到可中断句柄时的分类,决定这一轮能不能被兜底释放。 #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum StaleTurnReleaseReason { @@ -4133,7 +4128,7 @@ fn sync_direct_codex_project_outputs_at( /// Reasoning wrappers are still removed because they are not reply text, but /// the user owns the project and the resulting reply is not redacted here. /// -/// `pub(crate)`:回合流(`direct_turn_stream`)落最终回复前复用同一套可见性投影。 +/// `pub(crate)`:运行态与历史投影共用同一套可见性判断。 pub(crate) fn project_direct_codex_visible_text(value: &str) -> Option { let stripped = strip_incomplete_direct_thinking_marker(&strip_llm_thinking_blocks(value)); if stripped.trim().is_empty() { @@ -4314,7 +4309,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitt let prompt = prompt.trim(); emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); if let Some(emitter) = turn_emitter { - emitter.emit("accepted", Some("request-accepted"), None, None); + emitter.emit("accepted", Some("request-accepted")); } match run_direct_game_creator_turn_inner( root, @@ -4342,19 +4337,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitt // 用户消息,原因只在当轮界面与宿主诊断里。以后要给"进历史但不喂模型"的条目留一条通道 // (见 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md` 的备选方案第 3 条)。 if let Some(emitter) = turn_emitter { - // 失败说明也是这一回合的内容:按出现顺序追加到回合流末尾, - // 这样"流里已经是完整内容"这一点对失败回合同样成立。 - let failure_item = append_direct_turn_stream_text_at( - root, - emitter.turn_id(), - DIRECT_TURN_STREAM_FAILURE_ITEM_ID, - &error, - ) - .ok() - .flatten() - .into_iter() - .collect::>(); - emitter.emit_with_stream_items("failed", Some("none"), None, None, failure_item); + emitter.emit("failed", Some("none")); } Err(DirectTurnError::TurnFailed { stage, @@ -4364,163 +4347,6 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitt } } -/// 本回合累积的工具调用条目(观察者写、回合末读)。 -type DirectToolCallCollector = std::sync::Arc>>; - -fn lock_direct_tool_call_collector( - collector: &DirectToolCallCollector, -) -> std::sync::MutexGuard<'_, Vec> { - collector - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) -} - -/// 单条工具调用落盘:走阻塞线程池(写文件要拿项目锁,不能在 async 运行时上直接跑)。 -/// 失败只返回错误交给调用方忽略,不打断回合。 -fn spawn_persist_direct_tool_call(root: &Path, call: &DirectToolCall) { - let root = root.to_path_buf(); - let call = call.clone(); - tauri::async_runtime::spawn_blocking(move || persist_direct_tool_call_at(&root, &call)); -} - -/// 回合结束整批落盘;失败时退回逐条 upsert,尽量把能写的写进去。 -fn persist_collected_direct_tool_calls(root: &Path, collector: &DirectToolCallCollector) { - let calls = { - let collected = lock_direct_tool_call_collector(collector); - collected.clone() - }; - if calls.is_empty() { - return; - } - if persist_direct_tool_calls_at(root, &calls).is_ok() { - return; - } - for call in &calls { - let _ = persist_direct_tool_call_at(root, call); - } -} - -/// 回合流条目落盘:与工具调用同一口径(阻塞线程池 + 项目锁)。 -fn spawn_persist_direct_turn_stream_item( - root: &Path, - item: &DirectTurnStreamItem, -) -> tauri::async_runtime::JoinHandle> { - let root = root.to_path_buf(); - let item = item.clone(); - tauri::async_runtime::spawn_blocking(move || upsert_direct_turn_stream_item_at(&root, &item)) -} - -/// 文本段落盘/下发的节流间隔:文本段是"整段累计 + 原地替换",不需要逐 delta 落盘。 -const DIRECT_TURN_STREAM_TEXT_THROTTLE_MS: u128 = 300; - -/// 正在增长的那一段文本。 -struct DirectTurnStreamPendingText { - /// 这一段对应的 Codex assistant item id(段身份)。 - item_id: String, - item: DirectTurnStreamItem, - last_flush: std::time::Instant, -} - -/// 回合流的写入与下发状态(观察者持有)。 -/// -/// `seq_by_id` 是**顺序真相的本体**:条目 id 第一次出现时分配序号,之后所有更新都带同一个 -/// 序号,所以并发落盘的先后不会改变渲染顺序(不会出现"新工具插到旧文本前面")。 -struct DirectTurnStreamWriter { - turn_id: String, - seq_by_id: BTreeMap, - next_seq: u64, - last_updated_at: u64, - pending_text: Option, -} - -impl DirectTurnStreamWriter { - fn new(turn_id: String) -> Self { - Self { - turn_id, - seq_by_id: BTreeMap::new(), - next_seq: 0, - last_updated_at: 0, - pending_text: None, - } - } - - /// 条目 id 对应的固定序号:首次出现时分配,之后永远不变。 - fn seq_for(&mut self, id: &str) -> u64 { - if let Some(seq) = self.seq_by_id.get(id) { - return *seq; - } - self.next_seq += 1; - self.seq_by_id.insert(id.to_string(), self.next_seq); - self.next_seq - } - - /// 按 item 身份更新,段切换必须同时交出旧段尾快照与新段首快照。 - fn push_text( - &mut self, - root: &Path, - item_id: &str, - visible_text: &str, - now_ms: u64, - completed: bool, - ) -> Vec { - let now = std::time::Instant::now(); - let mut snapshots = Vec::new(); - self.last_updated_at = now_ms.max(self.last_updated_at.saturating_add(1)); - if self - .pending_text - .as_ref() - .is_some_and(|pending| pending.item_id != item_id) - { - snapshots.extend(self.take_pending_snapshot()); - } - if let Some(pending) = self.pending_text.as_mut() { - pending.item.text = Some(sanitize_stream_text(root, visible_text)); - pending.item.updated_at = self.last_updated_at; - if completed - || now.duration_since(pending.last_flush).as_millis() - >= DIRECT_TURN_STREAM_TEXT_THROTTLE_MS - { - pending.last_flush = now; - snapshots.push(pending.item.clone()); - } - } else { - let seq = self.seq_for(&direct_turn_stream_text_item_id(&self.turn_id, item_id)); - let item = direct_turn_stream_text_item( - root, - &self.turn_id, - item_id, - visible_text, - seq, - now_ms, - self.last_updated_at, - ); - snapshots.push(item.clone()); - self.pending_text = Some(DirectTurnStreamPendingText { - item_id: item_id.to_string(), - item, - last_flush: now, - }); - } - snapshots - } - - /// 取出当前段的收尾快照(段结束 / 回合结束时调用),不再持有它。 - fn take_pending_snapshot(&mut self) -> Option { - self.pending_text.take().map(|pending| pending.item) - } - - /// 工具条目:只记位置,正文仍来自 `tool-calls.jsonl`。 - fn push_tool(&mut self, call: &DirectToolCall, now_ms: u64) -> DirectTurnStreamItem { - let seq = self.seq_for(&direct_turn_stream_tool_item_id(&self.turn_id, &call.id)); - let at = if call.started_at > 0 { - call.started_at - } else { - now_ms - }; - direct_turn_stream_tool_item(&self.turn_id, call, seq, at) - } -} - async fn run_direct_game_creator_turn_inner( root: &Path, prompt: &str, @@ -4587,351 +4413,248 @@ async fn run_direct_game_creator_turn_inner( } // 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。 let result: Result = async { - if let Some(report) = super::direct_delivery::terminal_report(&execution_session) { - return Ok(report); - } - // CLI 没有结构化条目;在进入反馈循环前固定原始消息,避免后续反馈以同一 ID - // 写成内容不同的用户消息。GUI 则保留原有的 Skill、资源及附件引用。 - let direct_user_item = match direct_user_item { - Some(item) => item, - None => execution_session.snapshot().and_then(|state| { - direct_project_local_message_item( - "user", - prompt, - direct_codex_user_item_id_for_client_turn_id(&state.client_turn_id).as_deref(), - ) - }).map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?, - }; - if execution_session.newly_accepted { - if let Ok(ledger) = execution_session.snapshot() { - crate::analytics::goal::accepted( - capture.clone(), - root, - &ledger.project_id, - crate::analytics::contract::Source::Direct, - ); + if let Some(report) = super::direct_delivery::terminal_report(&execution_session) { + return Ok(report); } - } - emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); - if let Some(emitter) = turn_emitter { - emitter.emit("running", Some("preparing"), None, None); - } - // 本回合累积的工具调用条目:观察者增量采集,回合结束时整批落盘(幂等 upsert)。 - // 实时下发与落盘共用同一份数据,避免两处各采集一次产生口径差。 - let tool_calls: DirectToolCallCollector = Arc::new(Mutex::new(Vec::new())); - let stream_enabled = load_game_creator_app_config() - .map(|config| config.llm.stream) - .map_err(|error| { - DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, - error) - })?; - let previous_output_fingerprint = direct_codex_output_fingerprint(root); - let base_system_prompt = - build_direct_codex_system_prompt_with_creation_type(root, creation_type).map_err( - |error| DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, - error), - )?; - // 三维请求:把"自选三维技术栈、解除 Phaser 固定约束"的合同放在系统提示最前, - // 避免被长度上限截断,也不阻断任何工具。 - let engine_contract = direct_engine_three_dimensional_contract(root, prompt); - let system_prompt = match engine_contract.as_deref() { - Some(contract) => format!("{contract}\n{base_system_prompt}") - .chars() - .take(MAX_DIRECT_SYSTEM_PROMPT_CHARS) - .collect(), - None => base_system_prompt, - }; - let reply = if let Some(emitter) = turn_emitter { - let client_turn_id = emitter.turn_id().to_string(); - let emitter = emitter.clone(); - let turn_root = root.to_path_buf(); - let turn_tool_calls = Arc::clone(&tool_calls); - // 回合流:文本段与工具按**出现顺序**各占一行,位置(seq)在首次出现时钉死。 - let mut stream_writer = DirectTurnStreamWriter::new(client_turn_id.clone()); - let mut stream_writes = Vec::new(); - let mut observer = |observation: DirectCodexTurnObservation| { - let status = direct_codex_observation_status(&observation, stream_enabled); - match observation { - DirectCodexTurnObservation::AccumulatedText(accumulated_text) => { - let visible_text = - project_direct_codex_accumulated_text(stream_enabled, &accumulated_text); - if visible_text.is_none() { - return; - } - emitter.emit(status, None, visible_text, None); - } - DirectCodexTurnObservation::AgentMessageSegment { - item_id, - accumulated_text, - completed, - } => { - // 可见文本段:同一 item 的后续 delta 就地增长,item 变了才新起一段。 - let Some(visible_text) = project_direct_codex_visible_text(&accumulated_text) - else { - return; - }; - let items = stream_writer.push_text( - &turn_root, - &item_id, - &visible_text, - direct_tool_call_now_ms(), - completed, - ); - for item in &items { - stream_writes.push(spawn_persist_direct_turn_stream_item(&turn_root, item)); - } - if !items.is_empty() { - emitter.emit_with_stream_items(status, None, None, None, items); - } - } - DirectCodexTurnObservation::IntermediateText(intermediate_text) => { - let visible_text = if stream_enabled - || is_direct_codex_item_started_work_detail(&intermediate_text) - { - project_direct_codex_visible_text(&intermediate_text) - } else { - None - }; - if let Some(visible_text) = visible_text { - emitter.emit(status, None, Some(visible_text), None); - } - } - DirectCodexTurnObservation::Activity(activity) => { - emitter.emit(status, Some(activity), None, None); - } - DirectCodexTurnObservation::Reasoning(reasoning) => { - // 思考过程按"当前累计全文"下发(前端整段替换),状态保持 running: - // streaming 已被"用户可见正文"占用。 - emitter.emit_with_reasoning("running", None, None, None, Some(reasoning)); - } - DirectCodexTurnObservation::ToolCall(mut tool_call) => { - // 同一工具的开始、完成和详情补全共用一份单调快照。 - { - let collected = lock_direct_tool_call_collector(&turn_tool_calls); - let existing = collected - .iter() - .find(|existing| existing.id == tool_call.id); - if existing.is_some_and(|call| { - call.status != "running" && tool_call.status == "running" - }) { - return; - } - if !super::direct_tool_calls::direct_tool_call_status_changed( - existing, &tool_call, - ) { - return; - } - if let Some(existing) = existing { - tool_call.updated_at = tool_call - .updated_at - .max(existing.updated_at.saturating_add(1)); - tool_call = super::direct_tool_calls::merge_tool_call_snapshot( - existing, &tool_call, - ); - } - } - { - let mut collected = lock_direct_tool_call_collector(&turn_tool_calls); - collected.retain(|existing| existing.id != tool_call.id); - collected.push(tool_call.clone()); - } - // 回合流:工具是**普通元素**,位置在文本段之后(或与相邻工具成块)。 - let mut items = stream_writer - .take_pending_snapshot() - .into_iter() - .collect::>(); - items.push(stream_writer.push_tool(&tool_call, direct_tool_call_now_ms())); - for item in &items { - stream_writes.push(spawn_persist_direct_turn_stream_item(&turn_root, item)); - } - emitter.emit_with_stream_items( - status, - None, - None, - Some(vec![tool_call.clone()]), - items, - ); - // 落盘"最新的那一份":started 让卡片刷新后立刻出现,终态覆盖同一行。 - spawn_persist_direct_tool_call(&turn_root, &tool_call); - } - } + // CLI 没有结构化条目;在进入反馈循环前固定原始消息,避免后续反馈以同一 ID + // 写成内容不同的用户消息。GUI 则保留原有的 Skill、资源及附件引用。 + let direct_user_item = match direct_user_item { + Some(item) => item, + None => execution_session + .snapshot() + .and_then(|state| { + direct_project_local_message_item( + "user", + prompt, + direct_codex_user_item_id_for_client_turn_id(&state.client_turn_id) + .as_deref(), + ) + }) + .map_err(|error| { + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) + })?, }; - let mut feedback_prompt = prompt.to_string(); - let mut turn_kind = DirectCodexTurnKind::User; - let mut attempt = 1; - let reply_result = loop { - let result = direct_game_creator_codex_chat_at_with_optional_observer( - root, - system_prompt.clone(), - feedback_prompt.clone(), - turn_kind, - Some(&client_turn_id), - Some(&mut observer), - Some(direct_user_item.clone()), - ) - .await; - turn_kind = DirectCodexTurnKind::HostFeedback; - match result { - Ok(value) => match super::direct_delivery::review_reply(root, &execution_session).await { - Ok(Some(report)) => break Ok(report), - Ok(None) => break Ok(value), - // 返修要求是控制流,不是失败:把要求写回 prompt 再跑一轮。 - // `RepairRequired` 是同一族的第二条来源(app-server 封口复核),处理完全一样; - // 两条路的次数上限都在产生侧(交付复核 `ledger.max_runs`、执行账本的批次上限), - // 这里不另设计数,否则会把本来能收敛的长返修提前掐断。 - Err( - DirectTurnError::ReviewRequired { detail } - | DirectTurnError::RepairRequired { detail }, - ) => { - emitter.emit("running",Some("host-review"),Some("宿主复核发现必需证据尚未齐备,正在按冻结范围继续处理".into()),None); - feedback_prompt = format!(prompt_text!("direct.deliveryFeedback"),detail=detail); - } - Err(error) => break Err(error), + if execution_session.newly_accepted { + if let Ok(ledger) = execution_session.snapshot() { + crate::analytics::goal::accepted( + capture.clone(), + root, + &ledger.project_id, + crate::analytics::contract::Source::Direct, + ); + } + } + emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); + if let Some(emitter) = turn_emitter { + emitter.emit("running", Some("preparing")); + } + let stream_enabled = load_game_creator_app_config() + .map(|config| config.llm.stream) + .map_err(|error| { + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) + })?; + let previous_output_fingerprint = direct_codex_output_fingerprint(root); + let base_system_prompt = + build_direct_codex_system_prompt_with_creation_type(root, creation_type).map_err( + |error| { + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) }, - // 交付报告的兜底只读一次:guard 与取值各调一次会在两次之间换出不同结果, - // 第二次拿到 `None` 时还会把空串当成回复返回。 - Err(error) => match super::direct_delivery::terminal_report(&execution_session) { - Some(report) => break Ok(report), - None - if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && error.is_model_repairable() => - { - let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); - emitter.emit( - "running", - Some("error-feedback"), - Some(format!("检测到执行错误,正在反馈给陶泥儿继续修复({attempt}/{DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS})")), - None, + )?; + // 三维请求:把"自选三维技术栈、解除 Phaser 固定约束"的合同放在系统提示最前, + // 避免被长度上限截断,也不阻断任何工具。 + let engine_contract = direct_engine_three_dimensional_contract(root, prompt); + let system_prompt = match engine_contract.as_deref() { + Some(contract) => format!("{contract}\n{base_system_prompt}") + .chars() + .take(MAX_DIRECT_SYSTEM_PROMPT_CHARS) + .collect(), + None => base_system_prompt, + }; + let reply = if let Some(emitter) = turn_emitter { + let client_turn_id = emitter.turn_id().to_string(); + let emitter = emitter.clone(); + let mut observer = |observation: DirectCodexTurnObservation| { + let status = direct_codex_observation_status(&observation, stream_enabled); + match observation { + DirectCodexTurnObservation::AccumulatedText(accumulated_text) => { + let visible_text = project_direct_codex_accumulated_text( + stream_enabled, + &accumulated_text, ); - attempt += 1; - feedback_prompt = direct_codex_error_feedback_prompt(&detail); - } - // 失败也先走统一收尾,确保已提交的回合流快照全部落盘。 - None => break Err(error), - }, - } - }; - drop(observer); - if let Some(item) = stream_writer.take_pending_snapshot() { - stream_writes.push(spawn_persist_direct_turn_stream_item(&turn_root, &item)); - emitter.emit_with_stream_items("streaming", None, None, None, vec![item]); - } - // finalize 必须看见这一轮全部快照,不能与 fire-and-forget 写任务竞争。 - for write in stream_writes { - if !matches!(write.await, Ok(Ok(()))) { - app_log!("[turn-stream] 回合快照持久化失败"); - } - } - reply_result - } else { - let mut feedback_prompt = prompt.to_string(); - let mut turn_kind = DirectCodexTurnKind::User; - let mut attempt = 1; - let response = loop { - let result = direct_game_creator_codex_chat_at_with_optional_observer( - root, - system_prompt.clone(), - feedback_prompt.clone(), - turn_kind, - None, - None, - Some(direct_user_item.clone()), - ) - .await; - turn_kind = DirectCodexTurnKind::HostFeedback; - match result { - Ok(value) => { - match super::direct_delivery::review_reply(root, &execution_session).await { - Ok(Some(report)) => break Some(report), - Ok(None) => break Some(value), - // 返修要求是控制流,不是失败:把要求写回 prompt 再跑一轮。 - // `RepairRequired` 是同一族的第二条来源(app-server 封口复核),处理完全一样; - // 次数上限在产生侧(见流式分支同一处注释),这里不另设计数。 - Err( - DirectTurnError::ReviewRequired { detail } - | DirectTurnError::RepairRequired { detail }, - ) => { - feedback_prompt = format!(prompt_text!("direct.deliveryFeedback"),detail=detail); + if visible_text.is_none() { + return; } - // 回合级失败原样带出:typed 分类决定诊断摘要与建议,不再降级成文本。 - Err(error) => return Err(error), + emitter.emit(status, None); + } + DirectCodexTurnObservation::AgentMessageSegment(accumulated_text) => { + if project_direct_codex_visible_text(&accumulated_text).is_none() { + return; + } + emitter.emit(status, None); + } + DirectCodexTurnObservation::IntermediateText(intermediate_text) => { + let is_visible = stream_enabled + || is_direct_codex_item_started_work_detail(&intermediate_text); + if is_visible + && project_direct_codex_visible_text(&intermediate_text).is_some() + { + emitter.emit(status, None); + } + } + DirectCodexTurnObservation::Activity(activity) => { + emitter.emit(status, Some(activity)); + } + DirectCodexTurnObservation::Reasoning(_) => { + // 思考增量只把活动回合标成 running;正文由运行态事件投影下发。 + emitter.emit("running", None); } } - // 同上:交付报告只读一次,避免两次调用之间换出不同结果(第二次拿到 `None` - // 时还会绕过下面那条"未返回结果"的兜底错误)。 - Err(error) => match super::direct_delivery::terminal_report(&execution_session) { - Some(report) => break Some(report), - None - if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && error.is_model_repairable() => - { - let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); - attempt += 1; - feedback_prompt = direct_codex_error_feedback_prompt(&detail); + }; + let mut feedback_prompt = prompt.to_string(); + let mut turn_kind = DirectCodexTurnKind::User; + let mut attempt = 1; + let reply_result = loop { + let result = direct_game_creator_codex_chat_at_with_optional_observer( + root, + system_prompt.clone(), + feedback_prompt.clone(), + turn_kind, + Some(&client_turn_id), + Some(&mut observer), + Some(direct_user_item.clone()), + ) + .await; + turn_kind = DirectCodexTurnKind::HostFeedback; + match result { + Ok(value) => { + match super::direct_delivery::review_reply(root, &execution_session).await { + Ok(Some(report)) => break Ok(report), + Ok(None) => break Ok(value), + // 返修要求是控制流,不是失败:把要求写回 prompt 再跑一轮。 + // `RepairRequired` 是同一族的第二条来源(app-server 封口复核),处理完全一样; + // 两条路的次数上限都在产生侧(交付复核 `ledger.max_runs`、执行账本的批次上限), + // 这里不另设计数,否则会把本来能收敛的长返修提前掐断。 + Err( + DirectTurnError::ReviewRequired { detail } + | DirectTurnError::RepairRequired { detail }, + ) => { + emitter.emit("running", Some("host-review")); + feedback_prompt = format!( + prompt_text!("direct.deliveryFeedback"), + detail = detail + ); + } + Err(error) => break Err(error), + } } - None => return Err(error), - }, - } - }; - response.ok_or_else(|| { + // 交付报告的兜底只读一次:guard 与取值各调一次会在两次之间换出不同结果, + // 第二次拿到 `None` 时还会把空串当成回复返回。 + Err(error) => match super::direct_delivery::terminal_report(&execution_session) + { + Some(report) => break Ok(report), + None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS + && error.is_model_repairable() => + { + let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); + emitter.emit("running", Some("error-feedback")); + attempt += 1; + feedback_prompt = direct_codex_error_feedback_prompt(&detail); + } + None => break Err(error), + }, + } + }; + drop(observer); + reply_result + } else { + let mut feedback_prompt = prompt.to_string(); + let mut turn_kind = DirectCodexTurnKind::User; + let mut attempt = 1; + let response = loop { + let result = direct_game_creator_codex_chat_at_with_optional_observer( + root, + system_prompt.clone(), + feedback_prompt.clone(), + turn_kind, + None, + None, + Some(direct_user_item.clone()), + ) + .await; + turn_kind = DirectCodexTurnKind::HostFeedback; + match result { + Ok(value) => { + match super::direct_delivery::review_reply(root, &execution_session).await { + Ok(Some(report)) => break Some(report), + Ok(None) => break Some(value), + // 返修要求是控制流,不是失败:把要求写回 prompt 再跑一轮。 + // `RepairRequired` 是同一族的第二条来源(app-server 封口复核),处理完全一样; + // 次数上限在产生侧(见流式分支同一处注释),这里不另设计数。 + Err( + DirectTurnError::ReviewRequired { detail } + | DirectTurnError::RepairRequired { detail }, + ) => { + feedback_prompt = format!( + prompt_text!("direct.deliveryFeedback"), + detail = detail + ); + } + // 回合级失败原样带出:typed 分类决定诊断摘要与建议,不再降级成文本。 + Err(error) => return Err(error), + } + } + // 同上:交付报告只读一次,避免两次调用之间换出不同结果(第二次拿到 `None` + // 时还会绕过下面那条"未返回结果"的兜底错误)。 + Err(error) => match super::direct_delivery::terminal_report(&execution_session) + { + Some(report) => break Some(report), + None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS + && error.is_model_repairable() => + { + let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); + attempt += 1; + feedback_prompt = direct_codex_error_feedback_prompt(&detail); + } + None => return Err(error), + }, + } + }; + response.ok_or_else(|| { + DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + "陶泥儿错误反馈回合未返回结果", + ) + }) + }?; + let visible_reply = project_direct_codex_visible_text(&reply).ok_or_else(|| { DirectTurnError::turn_failed( DirectCodexFailureStage::CodeGeneration, - "陶泥儿错误反馈回合未返回结果", + "陶泥儿未返回可展示的回复".to_string(), ) - }) - }?; - // 回合结束:把本回合累积的工具调用整批落盘(一次锁、一次重写,幂等 upsert)。 - // 落盘失败只记日志,不能把已经成功的回合判成失败——工具调用卡片是展示数据。 - persist_collected_direct_tool_calls(root, &tool_calls); - let visible_reply = project_direct_codex_visible_text(&reply).ok_or_else(|| { - DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, - "陶泥儿未返回可展示的回复".to_string(), - ) - })?; - if let Some(emitter) = turn_emitter { - // 已有 item 文本由完成事件负责;只有完全没有 item 文本才补最终回复。 - let finalized = - finalize_direct_turn_stream_reply_at(root, emitter.turn_id(), &visible_reply) - .ok() - .flatten() - .into_iter() - .collect::>(); - emitter.emit_with_stream_items( - "finalizing", - Some("response-finalization"), - Some(visible_reply.clone()), - None, - finalized, - ); - } - if direct_codex_output_fingerprint(root) != previous_output_fingerprint { - emit_direct_game_creator_progress( - root, - "project.sync", - "检测到游戏文件更新,正在同步客户端资源", - ); + })?; if let Some(emitter) = turn_emitter { - emitter.emit( - "finalizing", - Some("file-write"), - Some(visible_reply.clone()), - None, - ); + emitter.emit("finalizing", Some("response-finalization")); } - sync_direct_codex_project_file_projection_at(root, Some(&previous_output_fingerprint)) - .map_err(|error| { - DirectTurnError::turn_failed( - DirectCodexFailureStage::VersionRegistration, - error) - })?; + if direct_codex_output_fingerprint(root) != previous_output_fingerprint { + emit_direct_game_creator_progress( + root, + "project.sync", + "检测到游戏文件更新,正在同步客户端资源", + ); + if let Some(emitter) = turn_emitter { + emitter.emit("finalizing", Some("file-write")); + } + sync_direct_codex_project_file_projection_at(root, Some(&previous_output_fingerprint)) + .map_err(|error| { + DirectTurnError::turn_failed( + DirectCodexFailureStage::VersionRegistration, + error, + ) + })?; + } + Ok(visible_reply) } - Ok(visible_reply) - }.await; + .await; if let Ok(ledger) = execution_session.snapshot() { if let (Some(metadata), Some((end_reason, error_code))) = ( &ledger.analytics_run, @@ -5045,38 +4768,6 @@ mod direct_analytics_tests { } } -#[cfg(test)] -mod direct_turn_stream_writer_tests { - use super::*; - - #[test] - fn item_switch_returns_previous_tail_and_next_head() { - let mut writer = DirectTurnStreamWriter::new("turn".into()); - let root = Path::new("."); - writer.push_text(root, "a", "前缀", 1000, false); - writer.push_text(root, "a", "完整正文", 1000, false); - let snapshots = writer.push_text(root, "b", "第二段", 1000, false); - assert_eq!(snapshots.len(), 2); - assert_eq!(snapshots[0].text.as_deref(), Some("完整正文")); - assert_eq!(snapshots[0].seq, 1); - assert_eq!(snapshots[1].seq, 2); - assert!(snapshots[1].updated_at > snapshots[0].updated_at); - } - - #[test] - fn completed_snapshot_bypasses_throttle_and_keeps_item_position() { - let mut writer = DirectTurnStreamWriter::new("turn".into()); - let root = Path::new("."); - let first = writer.push_text(root, "a", "前缀", 1000, false); - let completed = writer.push_text(root, "a", "完整正文", 1000, true); - assert_eq!(completed.len(), 1); - assert_eq!(completed[0].id, first[0].id); - assert_eq!(completed[0].seq, first[0].seq); - assert!(completed[0].updated_at > first[0].updated_at); - assert_eq!(completed[0].text.as_deref(), Some("完整正文")); - } -} - #[allow(dead_code)] async fn run_direct_game_creator_turn_with_private_editor_credentials( root: &Path, @@ -5821,35 +5512,6 @@ mod tests { ); } - #[test] - fn direct_turn_update_payload_is_the_exact_camel_case_contract() { - let value = serde_json::to_value(GameCreatorDirectTurnUpdateEvent { - project_path: "/project".to_string(), - turn_id: "abcdef-123456".to_string(), - sequence: 7, - status: "streaming".to_string(), - activity: None, - accumulated_text: Some("partial".to_string()), - tool_calls: None, - reasoning_text: None, - stream_items: None, - updated_at: 42, - }) - .expect("serialize direct update"); - assert_eq!( - value, - serde_json::json!({ - "projectPath": "/project", - "turnId": "abcdef-123456", - "sequence": 7, - "status": "streaming", - "activity": null, - "accumulatedText": "partial", - "updatedAt": 42, - }) - ); - } - #[test] fn system_prompt_is_bounded_and_declares_direct_runtime() { let prompt = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 7b7cc68d4..d907d60b6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -43,6 +43,7 @@ pub(crate) fn normalize_direct_client_turn_id( /// 终端上)已经退役,不要再为"手工跑一轮"新增第二条直接起回合的路径。 /// /// 设计见 `docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 +#[cfg(not(test))] #[tauri::command] pub(crate) async fn enqueue_direct_codex_turn( project_path: String, @@ -102,7 +103,7 @@ async fn enqueue_direct_codex_turn_typed( })?; // 入队:到这里这一条已经过了全部检查,剩下的就是排队等放行。条目只带走它自己的事实 // (用户条目、创建类型、入队时刻),canonical 形状与 prompt 放行时从它重投影——放行没有失败出口。 - let pending = PendingTurn::new(turn_id, user_item, creation_type, direct_tool_call_now_ms()); + let pending = PendingTurn::new(turn_id, user_item, creation_type, direct_now_ms()); match enqueue_pending_turn(&thread_id, pending) { Ok(_) => {} Err(EnqueueRejection::QueueFull) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs deleted file mode 100644 index 9d83b435d..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs +++ /dev/null @@ -1,1197 +0,0 @@ -//! GameAgent 对话「工具调用卡片」的采集与持久化。 -//! -//! 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`: -//! Codex app-server 的 `item/started` / `item/completed` 里带着完整的命令 / 文件变更 -//! 信息,这里把它们投影成结构化的 `DirectToolCall`,落到**独立文件** -//! `/.agent/conversations/tool-calls.jsonl`。 -//! -//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的 -//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 - -use super::thread_manager::wire::sanitize_detail_text; -use crate::config::write_game_creator_private_file; -use crate::project::{enforce_project_permission_policy, project_append_lock_for}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::BTreeMap; -use std::fs::File; -use std::io::{BufRead, BufReader}; -use std::path::{Path, PathBuf}; - -/// 行信封类型,与既有历史文件同构(`{"type": …, "payload": {…}}`)。 -pub(crate) const DIRECT_TOOL_CALL_RECORD_TYPE: &str = "tool_call_item"; -/// 条目 schema 版本。 -pub(crate) const DIRECT_TOOL_CALL_SCHEMA_VERSION: &str = "agc-tool-call.v1"; -/// 落盘上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。 -pub(crate) const DIRECT_TOOL_CALL_LIMIT: usize = 200; -/// `detail.command` / `detail.output` 的字符上限。 -const DIRECT_TOOL_CALL_DETAIL_MAX_CHARS: usize = 4000; -/// 折叠态摘要(`summary`)的字符上限。 -const DIRECT_TOOL_CALL_SUMMARY_MAX_CHARS: usize = 120; -/// 单条变更路径的字符上限。 -const DIRECT_TOOL_CALL_PATH_MAX_CHARS: usize = 300; - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectToolCallChange { - pub(crate) path: String, - /// `add` | `update` | `delete` - pub(crate) kind: String, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectToolCallDetail { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) command: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) output: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) changes: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectToolCall { - pub(crate) schema_version: String, - /// Codex item 的 id。同一 item 的 started/completed 共用它,落盘时按它幂等 upsert。 - pub(crate) id: String, - pub(crate) turn_id: String, - /// `command` | `file_change` | `mcp_tool` | `web_search` | `context_compaction` | `other` - pub(crate) kind: String, - /// 折叠态标题,按 kind 固定(`command` → `执行命令`、`file_change` → `编辑 N 个文件`)。 - pub(crate) title: String, - /// 折叠态标题后面的短摘要。 - pub(crate) summary: String, - /// `running` | `completed` | `failed` - pub(crate) status: String, - pub(crate) detail: DirectToolCallDetail, - pub(crate) started_at: u64, - pub(crate) updated_at: u64, -} - -impl DirectToolCall { - fn timestamp(&self) -> u64 { - if self.updated_at > 0 { - self.updated_at - } else { - self.started_at - } - } -} - -fn tool_calls_path(root: &Path) -> PathBuf { - root.join(".agent/conversations/tool-calls.jsonl") -} - -/// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。 -fn bounded_chars(value: &str, max_chars: usize) -> String { - if value.chars().count() <= max_chars { - return value.to_string(); - } - let mut truncated = value.chars().take(max_chars).collect::(); - truncated.push('…'); - truncated -} - -/// 取值的首行并按字符数截断(命令的摘要习惯)。 -fn first_line_bounded(value: &str, max_chars: usize) -> String { - let first_line = value.lines().next().unwrap_or_default().trim(); - bounded_chars(first_line, max_chars) -} - -/// 把 app-server 中可能是字符串或 JSON 对象的工具详情统一转成可读文本。 -fn direct_tool_call_value_text(value: &Value) -> Option { - match value { - Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()), - Value::Null => None, - // 调用方先脱敏再截断,不能在这里截断掉敏感字段的语法边界。 - _ => serde_json::to_string_pretty(value).ok(), - } -} - -fn tool_call_kind(item_type: &str) -> Option<&'static str> { - match item_type { - "commandExecution" => Some("command"), - "fileChange" => Some("file_change"), - "mcpToolCall" => Some("mcp_tool"), - "webSearch" => Some("web_search"), - "contextCompaction" => Some("context_compaction"), - // todoList / reasoning / plan / agentMessage 之类不属于「工具调用」,不落卡片。 - "todoList" | "reasoning" | "plan" | "agentMessage" | "message" | "userMessage" => None, - _ => Some("other"), - } -} - -fn direct_tool_call_changes(item: &Value) -> Vec { - item.get("changes") - .and_then(Value::as_array) - .map(|changes| { - changes - .iter() - .filter_map(|change| { - let path = change - .get("path") - .and_then(Value::as_str) - .map(str::trim) - .filter(|path| !path.is_empty())?; - Some(DirectToolCallChange { - path: bounded_chars(path, DIRECT_TOOL_CALL_PATH_MAX_CHARS), - kind: change - .get("kind") - .and_then(Value::as_str) - .unwrap_or("update") - .to_string(), - }) - }) - .collect::>() - }) - .unwrap_or_default() -} - -fn direct_tool_call_title(kind: &str, changes: &[DirectToolCallChange]) -> String { - match kind { - "command" => "执行命令".to_string(), - "file_change" => { - let mut paths = changes - .iter() - .map(|change| change.path.as_str()) - .collect::>(); - paths.sort_unstable(); - paths.dedup(); - if paths.is_empty() { - "编辑文件".to_string() - } else { - format!("编辑 {} 个文件", paths.len()) - } - } - "mcp_tool" => "调用工具".to_string(), - "web_search" => "搜索资料".to_string(), - "context_compaction" => "整理上下文".to_string(), - _ => "调用工具".to_string(), - } -} - -fn direct_tool_call_status(item: &Value, completed: bool) -> &'static str { - // item 自带的显式终态优先:被策略拒绝(declined)、失败、取消的调用不能因为 - // `completed == true` 就被当成成功,否则卡片会把"没执行成功"显示成"已执行"。 - if let Some(status) = item.get("status").and_then(Value::as_str) { - match status { - "completed" => return "completed", - "failed" | "declined" | "cancelled" | "canceled" | "aborted" => return "failed", - _ => {} - } - } - // Codex 的退出码约定:非 0 即失败;缺席时按"已完成"处理。 - if let Some(exit_code) = item.get("exitCode").and_then(Value::as_i64) { - return if exit_code == 0 { - "completed" - } else { - "failed" - }; - } - if let Some(success) = item.get("success").and_then(Value::as_bool) { - return if success { "completed" } else { "failed" }; - } - if completed { - "completed" - } else { - "running" - } -} - -/// 状态或可见详情变化才下发;同状态的输入/输出补全也属于更新。 -pub(crate) fn direct_tool_call_status_changed( - existing: Option<&DirectToolCall>, - incoming: &DirectToolCall, -) -> bool { - !existing.is_some_and(|current| { - current.status == incoming.status - && current.detail == incoming.detail - && current.title == incoming.title - && current.summary == incoming.summary - }) -} - -/// 把一条 Codex item 投影成工具调用条目。非工具类 item 返回 `None`。 -/// -/// `started_at` / `updated_at`:item 自己带的 `startedAtMs` / `completedAtMs` 优先, -/// 两处都没有时才用调用方给的回退值(本机毫秒时间戳)。 -pub(crate) fn direct_tool_call_from_item( - root: &Path, - item: &Value, - turn_id: &str, - completed: bool, - now_ms: u64, -) -> Option { - let item_type = item.get("type").and_then(Value::as_str)?; - let kind = tool_call_kind(item_type)?; - let id = item - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|id| !id.is_empty())?; - - let item_started_at = item - .get("startedAtMs") - .and_then(Value::as_u64) - .unwrap_or_default(); - let item_completed_at = item - .get("completedAtMs") - .and_then(Value::as_u64) - .unwrap_or_default(); - let started_at = if item_started_at > 0 { - item_started_at - } else if item_completed_at > 0 { - item_completed_at - } else { - now_ms - }; - let updated_at = if item_completed_at > 0 { - item_completed_at - } else { - started_at.max(now_ms) - }; - - let command = item - .get("command") - .and_then(Value::as_str) - .map(str::trim) - .filter(|command| !command.is_empty()) - .map(|command| sanitize_detail_text(root, command)) - .map(|command| bounded_chars(&command, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS)) - .or_else(|| { - item.get("arguments") - .and_then(direct_tool_call_value_text) - .map(|arguments| sanitize_detail_text(root, &arguments)) - .map(|arguments| bounded_chars(&arguments, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS)) - }); - let output = ["aggregatedOutput", "output", "result", "error"] - .iter() - .find_map(|key| item.get(key).and_then(direct_tool_call_value_text)) - .map(|output| sanitize_detail_text(root, &output)) - .map(|output| bounded_chars(&output, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS)); - // `fileChange` 的路径先脱敏成"项目内相对路径":绝对路径会被抹成 ``, - // 相对路径原样保留(契约要求 detail.changes[].path 用项目相对路径)。 - let changes = direct_tool_call_changes(item) - .into_iter() - .map(|change| DirectToolCallChange { - path: sanitize_detail_text(root, &change.path), - kind: change.kind, - }) - .collect::>(); - - let tool = item - .get("tool") - .and_then(Value::as_str) - .map(str::trim) - .filter(|tool| !tool.is_empty()) - .map(|tool| sanitize_detail_text(root, tool)); - // `summary` 会落到卡片与落盘文件,它的兜底来源同样必须脱敏。 - let summary_source = (if kind == "mcp_tool" { - tool.as_deref() - } else { - None - }) - .or(command.as_deref()) - .or_else(|| changes.first().map(|change| change.path.as_str())) - .or(tool.as_deref()) - .unwrap_or_default(); - let summary = first_line_bounded(summary_source, DIRECT_TOOL_CALL_SUMMARY_MAX_CHARS); - - Some(DirectToolCall { - schema_version: DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(), - id: id.to_string(), - turn_id: turn_id.trim().to_string(), - kind: kind.to_string(), - title: direct_tool_call_title(kind, &changes), - summary, - status: direct_tool_call_status(item, completed).to_string(), - detail: DirectToolCallDetail { - command, - output, - changes, - }, - started_at, - updated_at, - }) -} - -fn record_line(call: &DirectToolCall) -> Result { - serde_json::to_string(&serde_json::json!({ - "type": DIRECT_TOOL_CALL_RECORD_TYPE, - "payload": call, - })) - .map_err(|error| format!("序列化工具调用条目失败:{error}")) -} - -/// 解析一行信封;坏行 / 非本文件条目都返回 `None`(尽力而为的展示数据,不整体失败)。 -fn tool_call_from_line(line: &str) -> Option { - let trimmed = line.trim(); - if trimmed.is_empty() { - return None; - } - let parsed: Value = serde_json::from_str(trimmed).ok()?; - if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_TOOL_CALL_RECORD_TYPE) { - return None; - } - let payload = parsed.get("payload")?; - let mut call: DirectToolCall = serde_json::from_value(payload.clone()).ok()?; - if call.id.trim().is_empty() { - return None; - } - if call.schema_version.trim().is_empty() { - call.schema_version = DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(); - } - Some(call) -} - -fn read_tool_call_lines(path: &Path) -> Vec { - let Ok(file) = File::open(path) else { - return Vec::new(); - }; - let mut reader = BufReader::new(file); - let mut buffer = Vec::new(); - let mut calls = Vec::new(); - loop { - buffer.clear(); - match reader.read_until(b'\n', &mut buffer) { - Ok(0) => break, - // 单行解码失败(非法 UTF-8)只跳过这一行,继续读后面的行; - // 契约要求「单行损坏跳过该行继续」,不能把后续记录一起丢掉。 - Ok(_) => match std::str::from_utf8(&buffer) { - Ok(line) => { - if let Some(call) = tool_call_from_line(line) { - calls.push(call); - } - } - Err(_) => continue, - }, - // 读 I/O 错误:无法再定位下一行边界,停止读取(已读到的照常返回)。 - Err(_) => break, - } - } - calls -} - -/// 按 id 归并(同 id 按 `updatedAt` 单调合并),再按时间正序裁剪到最近 -/// `DIRECT_TOOL_CALL_LIMIT` 条。 -fn normalize_tool_calls(calls: Vec) -> Vec { - let mut by_id: BTreeMap = BTreeMap::new(); - for call in calls { - let merged = match by_id.remove(&call.id) { - Some(previous) => merge_tool_call_snapshot(&previous, &call), - None => call, - }; - by_id.insert(merged.id.clone(), merged); - } - let mut normalized = by_id.into_values().collect::>(); - normalized.sort_by(|left, right| { - left.timestamp() - .cmp(&right.timestamp()) - .then_with(|| left.id.cmp(&right.id)) - }); - if normalized.len() > DIRECT_TOOL_CALL_LIMIT { - normalized.drain(..normalized.len() - DIRECT_TOOL_CALL_LIMIT); - } - normalized -} - -/// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。 -fn status_certainty(status: &str) -> u8 { - match status { - "completed" | "failed" => 1, - _ => 0, - } -} - -/// 同一 id 的两条快照按 `updatedAt` 做**单调合并**。 -/// -/// - `startedAt` 取最早的非零值:`item/completed` 事件不一定带 `startedAtMs`, -/// 不能让 completed 覆盖掉 started 记下的起点(卡片时间序依赖它)。 -/// - `updatedAt` 更旧的快照不得覆盖更新的状态与 `updatedAt`:`direct_runtime.rs` 里 -/// 「回合末整批落盘」与「逐条快照落盘(spawn_blocking)」两条路径竞争时,后到的 -/// 旧快照不能把已经 `completed` / `failed` 的卡片打回 `running`。 -/// - `updatedAt` 相同时终态优先,避免同一毫秒内的旧快照回退状态。 -pub(crate) fn merge_tool_call_snapshot( - existing: &DirectToolCall, - incoming: &DirectToolCall, -) -> DirectToolCall { - let take_incoming = incoming.updated_at > existing.updated_at - || (incoming.updated_at == existing.updated_at - && status_certainty(&incoming.status) > status_certainty(&existing.status)); - let mut merged = if take_incoming { - incoming.clone() - } else { - existing.clone() - }; - if merged.detail.command.is_none() { - merged.detail.command = existing - .detail - .command - .clone() - .or(incoming.detail.command.clone()); - } - if merged.detail.output.is_none() { - merged.detail.output = existing - .detail - .output - .clone() - .or(incoming.detail.output.clone()); - } - merged.started_at = [merged.started_at, existing.started_at, incoming.started_at] - .into_iter() - .filter(|started_at| *started_at > 0) - .min() - .unwrap_or_default(); - merged -} - -/// 幂等 upsert:同 id 只保留一行,快照按 `updatedAt` 单调合并(旧快照不得回退状态)。 -/// -/// 单次尝试的顺序是「取项目锁 + append 锁 → 锁内读 → 整文件原子替换」。 -/// 工具调用是**追加 + 就地更新**混用的数据,没有纯追加的 JSONL 语义,所以只能整文件重写; -/// 文件规模由 200 条上限与 4000 字符截断兜住。 -fn upsert_direct_tool_call_once(root: &Path, call: &DirectToolCall) -> Result<(), String> { - let path = tool_calls_path(root); - let _project_lock = crate::acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "conversation.write", - )?; - let lock = project_append_lock_for(&path)?; - let _append_guard = lock.lock("工具调用历史写入")?; - let mut calls = read_tool_call_lines(&path); - let existing = calls - .iter() - .find(|existing| existing.id == call.id) - .cloned(); - let incoming = match existing.as_ref() { - Some(existing) => merge_tool_call_snapshot(existing, call), - None => call.clone(), - }; - calls.retain(|existing| existing.id != incoming.id); - calls.push(incoming); - let normalized = normalize_tool_calls(calls); - let mut body = String::new(); - for existing in &normalized { - body.push_str(&record_line(existing)?); - body.push('\n'); - } - write_game_creator_private_file(&path, body.as_bytes(), "工具调用历史") -} - -/// 落盘入口。失败不抛给调用方以外的地方——工具调用是展示数据,不能因为它把整轮判失败。 -pub(crate) fn persist_direct_tool_call_at( - root: &Path, - call: &DirectToolCall, -) -> Result<(), String> { - enforce_project_permission_policy(root, "conversation.write")?; - upsert_direct_tool_call_once(root, call) -} - -/// 一轮结束时把本回合累积的工具调用整批落盘(一次锁、一次重写)。 -pub(crate) fn persist_direct_tool_calls_at( - root: &Path, - calls: &[DirectToolCall], -) -> Result<(), String> { - if calls.is_empty() { - return Ok(()); - } - enforce_project_permission_policy(root, "conversation.write")?; - let path = tool_calls_path(root); - let _project_lock = crate::acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "conversation.write", - )?; - let lock = project_append_lock_for(&path)?; - let _append_guard = lock.lock("工具调用历史写入")?; - let mut existing = read_tool_call_lines(&path); - let mut incoming = calls.to_vec(); - for call in incoming.iter_mut() { - let merged = existing - .iter() - .find(|row| row.id == call.id) - .map(|previous| merge_tool_call_snapshot(previous, call)); - if let Some(merged) = merged { - *call = merged; - } - } - let ids = incoming - .iter() - .map(|call| call.id.as_str()) - .collect::>(); - existing.retain(|call| !ids.contains(&call.id.as_str())); - existing.extend(incoming); - let normalized = normalize_tool_calls(existing); - let mut body = String::new(); - for call in &normalized { - body.push_str(&record_line(call)?); - body.push('\n'); - } - write_game_creator_private_file(&path, body.as_bytes(), "工具调用历史") -} - -/// 本机毫秒时间戳(item 没带时间时用)。 -pub(crate) fn direct_tool_call_now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .min(u64::MAX as u128) as u64 -} - -#[cfg(test)] -mod tests { - use super::{ - direct_tool_call_from_item, direct_tool_call_now_ms, direct_tool_call_status, - direct_tool_call_status_changed, persist_direct_tool_call_at, persist_direct_tool_calls_at, - read_tool_call_lines, sanitize_detail_text, tool_call_from_line, tool_calls_path, - DirectToolCall, DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, - DIRECT_TOOL_CALL_SCHEMA_VERSION, - }; - use serde_json::json; - use std::path::Path; - - /// 写侧用例直接读文件:回读命令退役后不再经过 `normalize_tool_calls` 的合并与裁剪, - /// 断言因此落在「磁盘上到底写了什么」这一层。 - fn persisted_tool_calls(root: &Path) -> Vec { - std::fs::read_to_string(tool_calls_path(root)) - .unwrap_or_default() - .lines() - .filter_map(tool_call_from_line) - .collect() - } - - /// 一行合法的落盘信封(回读用例的夹具)。 - fn tool_call_row(id: &str, started_at: u64, updated_at: u64) -> String { - serde_json::to_string(&json!({ - "type": "tool_call_item", - "payload": { - "schemaVersion": "agc-tool-call.v1", - "id": id, - "turnId": "turn-1", - "kind": "command", - "title": "执行命令", - "summary": "npm run build", - "status": "completed", - "detail": {"command": "npm run build"}, - "startedAt": started_at, - "updatedAt": updated_at - } - })) - .expect("serialize tool call row") - } - - fn init_tool_call_project(name: &str) -> tempfile::TempDir { - let root = tempfile::tempdir().expect("temp project"); - crate::init_local_game_project_at(root.path(), name, "工具调用卡片测试") - .expect("init project"); - root - } - - fn command_item(id: &str, command: &str) -> serde_json::Value { - json!({ - "id": id, - "type": "commandExecution", - "command": command, - "status": "inProgress", - "startedAtMs": 1000, - }) - } - - /// 判据:同一 item 的 started 与 completed 只落一行,completed 覆盖 status。 - fn sample_tool_call(id: &str, status: &str, updated_at: u64) -> DirectToolCall { - DirectToolCall { - schema_version: DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(), - id: id.to_string(), - turn_id: "turn-1".to_string(), - kind: "command".to_string(), - title: "执行命令".to_string(), - summary: "npm run build".to_string(), - status: status.to_string(), - detail: DirectToolCallDetail::default(), - started_at: 1, - updated_at, - } - } - - #[test] - fn tool_call_status_change_is_detected_only_on_real_changes() { - let running = sample_tool_call("call-1", "running", 1); - let completed = sample_tool_call("call-1", "completed", 2); - - assert!( - direct_tool_call_status_changed(None, &running), - "首次观察必须被收集" - ); - assert!( - !direct_tool_call_status_changed(Some(&running), &running), - "状态没变时不该重复下发同一份快照" - ); - assert!( - direct_tool_call_status_changed(Some(&running), &completed), - "running -> completed 的终态观察必须被收集与下发(历史 bug:这里被丢弃,卡片永远显示执行中)" - ); - } - - #[test] - fn explicit_declined_or_failed_status_is_not_reported_as_completed() { - for status in ["declined", "failed", "cancelled", "aborted"] { - let item = json!({ - "id": "call-1", - "type": "commandExecution", - "status": status, - }); - assert_eq!( - direct_tool_call_status(&item, true), - "failed", - "item 自带 {status} 时不能因为 completed=true 就被当成 completed" - ); - } - let completed = json!({ - "id": "call-1", - "type": "commandExecution", - "status": "completed", - }); - assert_eq!(direct_tool_call_status(&completed, true), "completed"); - } - - #[test] - fn tool_call_upsert_is_idempotent_per_item_id() { - let root = init_tool_call_project("tool-call-upsert"); - let started = direct_tool_call_from_item( - root.path(), - &command_item("item-1", "npm run build"), - "turn-1", - false, - 1000, - ) - .expect("started tool call"); - persist_direct_tool_call_at(root.path(), &started).expect("persist started"); - - let completed = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-1", - "type": "commandExecution", - "command": "npm run build", - "exitCode": 0, - "completedAtMs": 2000, - }), - "turn-1", - true, - 2000, - ) - .expect("completed tool call"); - persist_direct_tool_call_at(root.path(), &completed).expect("persist completed"); - - let calls = persisted_tool_calls(root.path()); - assert_eq!(calls.len(), 1, "同一 id 只能有一行"); - assert_eq!(calls[0].status, "completed"); - assert_eq!(calls[0].started_at, 1000, "startedAt 不被 completed 覆盖"); - assert_eq!(calls[0].updated_at, 2000); - } - - /// 判据:非 0 退出码判 failed。 - #[test] - fn tool_call_marks_failed_on_non_zero_exit_code() { - let root = init_tool_call_project("tool-call-failed"); - let call = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-failed", - "type": "commandExecution", - "command": "npm test", - "exitCode": 1, - "completedAtMs": 3000, - }), - "turn-1", - true, - 3000, - ) - .expect("failed tool call"); - assert_eq!(call.status, "failed"); - } - - /// 判据:command / output 截断到 4000 字符,summary 截断到 120 字符。 - #[test] - fn tool_call_truncates_command_output_and_summary() { - let root = init_tool_call_project("tool-call-truncate"); - let long_command = "a".repeat(5000); - let long_output = "b".repeat(5000); - let call = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-long", - "type": "commandExecution", - "command": long_command, - "aggregatedOutput": long_output, - "startedAtMs": 1000, - }), - "turn-1", - false, - 1000, - ) - .expect("long tool call"); - let command = call.detail.command.expect("bounded command"); - let output = call.detail.output.expect("bounded output"); - assert_eq!(command.chars().count(), 4001, "4000 字符 + 省略号"); - assert_eq!(output.chars().count(), 4001, "4000 字符 + 省略号"); - assert_eq!(call.summary.chars().count(), 121, "120 字符 + 省略号"); - } - - /// 判据:脱敏后不出现 API Key / Token / 绝对用户目录。 - #[test] - fn tool_call_redacts_secrets_and_absolute_paths() { - let root = init_tool_call_project("tool-call-redact"); - let call = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-secret", - "type": "commandExecution", - "command": "curl -H 'Authorization: Bearer sk-abcdefghijklmnop' https://example.com", - "aggregatedOutput": "OPENAI_API_KEY=tnr_sk_abcdefghijklmnop /home/someuser/private/notes.txt", - "startedAtMs": 1000, - }), - "turn-1", - false, - 1000, - ) - .expect("redacted tool call"); - let command = call.detail.command.as_deref().expect("command"); - let output = call.detail.output.as_deref().expect("output"); - assert!( - !command.contains("sk-abcdefghijklmnop"), - "命令里的 API Key 必须脱敏:{command}" - ); - assert!( - !output.contains("tnr_sk_abcdefghijklmnop"), - "输出里的 Token 必须脱敏:{output}" - ); - assert!( - !output.contains("/home/someuser"), - "输出里的绝对用户目录必须脱敏:{output}" - ); - - persist_direct_tool_call_at(root.path(), &call).expect("persist redacted call"); - let raw = std::fs::read_to_string(tool_calls_path(root.path())).expect("read raw file"); - assert!( - !raw.contains("sk-abcdefghijklmnop"), - "落盘文件里不得出现 API Key" - ); - assert!( - !raw.contains("/home/someuser"), - "落盘文件里不得出现绝对用户目录" - ); - } - - /// 判据:fileChange 的标题按去重后的变更数量,摘要取首个变更路径。 - #[test] - fn tool_call_file_change_title_counts_unique_paths() { - let root = init_tool_call_project("tool-call-file-change"); - let call = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-files", - "type": "fileChange", - "changes": [ - {"path": "game/src/a.ts", "kind": "update"}, - {"path": "game/src/a.ts", "kind": "update"}, - {"path": "game/src/b.ts", "kind": "add"} - ], - "startedAtMs": 1000, - }), - "turn-1", - false, - 1000, - ) - .expect("file change tool call"); - assert_eq!(call.kind, "file_change"); - assert_eq!(call.title, "编辑 2 个文件"); - assert_eq!(call.summary, "game/src/a.ts"); - assert_eq!(call.detail.changes.len(), 3); - } - - /// 判据:非工具类 item 不产卡片。 - #[test] - fn tool_call_skips_non_tool_items() { - let root = init_tool_call_project("tool-call-skip"); - for item_type in ["reasoning", "agentMessage", "todoList", "plan"] { - assert!( - direct_tool_call_from_item( - root.path(), - &json!({"id": "item-x", "type": item_type}), - "turn-1", - false, - direct_tool_call_now_ms(), - ) - .is_none(), - "{item_type} 不应产出工具调用卡片" - ); - } - } - /// 五类必须脱敏的凭据形状(审查报告实测泄漏的那五类)。 - const CREDENTIAL_CANARIES: [&str; 5] = [ - "canary-bearer-value", - "canary-cookie-value", - "canary-api-key-value", - "canary-client-secret-value", - "canary-password-value", - ]; - - /// 判据:`Authorization: Bearer` / `Cookie: session=` / `api_key=` / `client_secret=` / - /// `--password <值>` 五类凭据在投影结果与落盘行里都不得出现原始值。 - #[test] - fn tool_call_redacts_extended_credential_shapes() { - let root = init_tool_call_project("tool-call-credential-shapes"); - let command = [ - "curl -H 'Authorization: Bearer canary-bearer-value' https://example.com", - "curl -b 'Cookie: session=canary-cookie-value' https://example.com", - "curl -d api_key=canary-api-key-value https://example.com", - "curl -d client_secret=canary-client-secret-value https://example.com", - "vault login --password canary-password-value --env prod", - ] - .join("\n"); - let output = [ - "Authorization: Bearer canary-output-bearer-value", - "Cookie: session=canary-output-cookie-value", - ] - .join("\n"); - let call = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-credentials", - "type": "commandExecution", - "command": command, - "aggregatedOutput": output, - "startedAtMs": 1000, - }), - "turn-1", - false, - 1000, - ) - .expect("credential tool call"); - - let projected_command = call.detail.command.as_deref().expect("command"); - let projected_output = call.detail.output.as_deref().expect("output"); - for canary in CREDENTIAL_CANARIES { - assert!( - !projected_command.contains(canary), - "命令投影里不得出现原始凭据 {canary}:{projected_command}" - ); - } - for canary in ["canary-output-bearer-value", "canary-output-cookie-value"] { - assert!( - !projected_output.contains(canary), - "输出投影里不得出现原始凭据 {canary}:{projected_output}" - ); - } - assert!( - !call.summary.contains("canary-bearer-value"), - "摘要取自命令首行,同样不得带原始凭据:{}", - call.summary - ); - - persist_direct_tool_call_at(root.path(), &call).expect("persist credential call"); - let raw = std::fs::read_to_string(tool_calls_path(root.path())).expect("read raw file"); - for canary in CREDENTIAL_CANARIES { - assert!( - !raw.contains(canary), - "落盘行里不得出现原始凭据 {canary}:{raw}" - ); - } - for canary in ["canary-output-bearer-value", "canary-output-cookie-value"] { - assert!( - !raw.contains(canary), - "落盘行里不得出现原始凭据 {canary}:{raw}" - ); - } - } - - /// 判据:脱敏不误伤正常内容、既有前缀脱敏不回退、且幂等(连跑两次结果一致)。 - #[test] - fn tool_call_redaction_keeps_normal_text_and_is_idempotent() { - let root = init_tool_call_project("tool-call-redaction-idempotent"); - let sanitize = |value: &str| sanitize_detail_text(root.path(), value); - - // 出现 `password` 单词但没有赋值 → 属于正常内容,不得脱敏。 - let plain = "grep -n password game/src/config.ts"; - let once = sanitize(plain); - assert_eq!(once, plain, "没有赋值的 password 单词不得被脱敏"); - assert_eq!(sanitize(&once), once, "脱敏必须幂等"); - - // 既有前缀脱敏(sk-…)不得回退。 - let prefixed = "curl -H 'X-Api-Key: sk-canary-prefix-key' https://example.com"; - let once = sanitize(prefixed); - assert!( - !once.contains("sk-canary-prefix-key"), - "既有前缀脱敏不得回退:{once}" - ); - assert_eq!(sanitize(&once), once, "脱敏必须幂等"); - - // `--password <值>`:沿用既有 fail-closed 约定(含敏感 CLI 标志的行整行替换), - // 原始值随之消失,且再次脱敏结果不变。 - let with_secret = "vault login --password canary-password-value --env prod"; - let once = sanitize(with_secret); - assert!( - !once.contains("canary-password-value"), - "`--password <值>` 不得落盘明文:{once}" - ); - assert_eq!(sanitize(&once), once, "脱敏必须幂等"); - - // `--password $ENV`:占位符不是密钥,但既有 `contains_sensitive_cli_flag` 按标志 - // fail-closed 整行替换(与 sanitize_error_context 一致),本次属契约内行为。 - let placeholder = "vault login --password $DEPLOY_PASSWORD --env prod"; - let once = sanitize(placeholder); - assert_eq!( - once, "[redacted sensitive context]", - "含敏感 CLI 标志的行按既有约定整行替换" - ); - assert_eq!(sanitize(&once), once, "脱敏必须幂等"); - } - - /// 判据:同 id 的快照按 `updatedAt` 单调合并——后到的旧快照不得把终态打回 `running`, - /// 也不得回退 `updatedAt`;`startedAt` 仍取最早。 - #[test] - fn tool_call_persist_keeps_newest_snapshot_per_item() { - let root = init_tool_call_project("tool-call-monotonic"); - let running = direct_tool_call_from_item( - root.path(), - &command_item("item-1", "npm run build"), - "turn-1", - false, - 1000, - ) - .expect("running tool call"); - assert_eq!(running.status, "running"); - let completed = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-1", - "type": "commandExecution", - "command": "npm run build", - "exitCode": 0, - "completedAtMs": 2000, - }), - "turn-1", - true, - 2000, - ) - .expect("completed tool call"); - assert_eq!(completed.status, "completed"); - assert_eq!(completed.updated_at, 2000); - - persist_direct_tool_call_at(root.path(), &completed).expect("persist completed first"); - persist_direct_tool_call_at(root.path(), &running).expect("persist stale running"); - let calls = persisted_tool_calls(root.path()); - assert_eq!(calls.len(), 1, "同一 id 只能有一行"); - assert_eq!( - calls[0].status, "completed", - "后到的旧快照不得把 completed 打回 running" - ); - assert_eq!(calls[0].updated_at, 2000, "旧快照不得回退 updatedAt"); - assert_eq!(calls[0].started_at, 1000, "startedAt 仍取最早"); - - // 回合末整批落盘那条路径同样不得回退。 - persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running)) - .expect("persist stale running batch"); - let calls = persisted_tool_calls(root.path()); - assert_eq!( - calls[0].status, "completed", - "整批落盘路径同样不得把 completed 打回 running" - ); - assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt"); - } - - /// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。 - #[test] - fn tool_call_paths_become_project_relative() { - let root = init_tool_call_project("tool-call-path-shape"); - let root_display = root.path().to_string_lossy().to_string(); - let inside = root - .path() - .join("game/src/x.ts") - .to_string_lossy() - .to_string(); - let outside = if cfg!(windows) { - r"C:\Windows\Temp\canary-outside.ts".to_string() - } else { - "/opt/canary/outside.ts".to_string() - }; - let call = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-paths", - "type": "fileChange", - "changes": [ - {"path": inside, "kind": "update"}, - {"path": outside, "kind": "add"}, - ], - "startedAtMs": 1000, - }), - "turn-1", - false, - 1000, - ) - .expect("path tool call"); - let paths = call - .detail - .changes - .iter() - .map(|change| change.path.as_str()) - .collect::>(); - assert_eq!( - paths[0], "game/src/x.ts", - "项目内绝对路径必须落成项目相对路径(不能是占位符)" - ); - assert_eq!(paths[1], "", "项目外绝对路径保持占位形状"); - assert_eq!(call.summary, "game/src/x.ts", "摘要取首个变更路径"); - - persist_direct_tool_call_at(root.path(), &call).expect("persist path call"); - let raw = std::fs::read_to_string(tool_calls_path(root.path())).expect("read raw file"); - assert!( - !raw.contains(&root_display), - "落盘不得残留项目根目录:{raw}" - ); - } - - /// 判据:写前读取时单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回, - /// 否则一次截断写入会把整份工具卡片从后续重写里抹掉。 - #[test] - fn tool_call_pre_read_skips_invalid_utf8_line() { - let root = init_tool_call_project("tool-call-invalid-utf8"); - let path = tool_calls_path(root.path()); - std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); - - // 形态一(契约原文):合法行 + 非法字节行 + 合法行 → 读回 2 条。 - let mut bytes = Vec::new(); - bytes.extend_from_slice(tool_call_row("item-a", 1000, 1000).as_bytes()); - bytes.push(b'\n'); - bytes.extend_from_slice(&[0xff, 0xfe, b'\n']); - bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes()); - bytes.push(b'\n'); - std::fs::write(&path, &bytes).expect("write invalid utf8 fixture"); - let calls = read_tool_call_lines(&path); - assert_eq!( - calls.len(), - 2, - "非法 UTF-8 行只跳过该行,后面的合法记录必须读回" - ); - assert_eq!(calls[0].id, "item-a"); - assert_eq!(calls[1].id, "item-b"); - - // 形态二:损坏行缺换行(写入被截断),与紧随其后的记录黏成一行。 - // 此时被丢掉的只有黏连的那一行,其后的合法记录必须继续读回。 - let mut bytes = Vec::new(); - bytes.extend_from_slice(tool_call_row("item-a", 1000, 1000).as_bytes()); - bytes.push(b'\n'); - bytes.push(0xff); - bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes()); - bytes.push(b'\n'); - bytes.extend_from_slice(tool_call_row("item-c", 3000, 3000).as_bytes()); - bytes.push(b'\n'); - std::fs::write(&path, &bytes).expect("write truncated utf8 fixture"); - let calls = read_tool_call_lines(&path); - assert_eq!( - calls.len(), - 2, - "损坏行缺换行时只丢黏连的那一行,其后的合法记录必须继续读回" - ); - assert_eq!(calls[0].id, "item-a"); - assert_eq!(calls[1].id, "item-c"); - - let missing = tempfile::tempdir().expect("missing dir"); - assert!( - read_tool_call_lines(&tool_calls_path(missing.path())).is_empty(), - "历史文件缺失时写前读取必须返回空表" - ); - } - - /// 判据:落到磁盘上的行同样受 200 条上限约束——「按时间保留最新 200 条」,更早回合的 - /// 卡片会被静默丢弃(契约内行为,不是缺陷)。本用例只钉住现状与时间正序。 - #[test] - fn tool_call_cap_drops_oldest_turn_cards() { - let root = init_tool_call_project("tool-call-cap-oldest"); - let old_turn = (0..DIRECT_TOOL_CALL_LIMIT) - .map(|index| { - direct_tool_call_from_item( - root.path(), - &json!({ - "id": format!("item-{index:04}"), - "type": "commandExecution", - "command": format!("run {index}"), - "startedAtMs": 1000 + index as u64, - }), - "turn-old", - false, - 1000 + index as u64, - ) - .expect("old turn tool call") - }) - .collect::>(); - persist_direct_tool_calls_at(root.path(), &old_turn).expect("persist old turn"); - let newest = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-newest", - "type": "commandExecution", - "command": "run newest", - "startedAtMs": 90_000, - }), - "turn-new", - false, - 90_000, - ) - .expect("newest tool call"); - persist_direct_tool_call_at(root.path(), &newest).expect("persist newest"); - - let read = persisted_tool_calls(root.path()); - assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条"); - assert_eq!( - read.last().expect("last").id, - "item-newest", - "最新回合的卡片必须在" - ); - assert_eq!( - read.first().expect("first").id, - "item-0001", - "最旧回合的卡片被静默丢弃(老回合卡片会消失)" - ); - assert!( - read.windows(2) - .all(|pair| pair[0].timestamp() <= pair[1].timestamp()), - "落盘顺序必须按时间正序" - ); - } - - /// 判据:项目内路径的 `\` / `/` 两种写法与大小写变体都要落成同一份项目相对路径 - /// (Codex 上报的路径分隔符与盘符大小写不受我们控制)。 - #[test] - fn tool_call_paths_normalize_separators_and_case() { - let root = init_tool_call_project("tool-call-path-variants"); - let native = root.path().to_string_lossy().to_string(); - let variants = [native.replace('\\', "/"), native.to_ascii_uppercase()]; - let mut paths = Vec::new(); - for (index, variant) in variants.into_iter().enumerate() { - let call = direct_tool_call_from_item( - root.path(), - &json!({ - "id": format!("item-path-variant-{index}"), - "type": "fileChange", - "changes": [{"path": format!("{variant}/game/src/y.ts"), "kind": "update"}], - "startedAtMs": 1000, - }), - "turn-1", - false, - 1000, - ) - .expect("variant path tool call"); - paths.push(call.detail.changes[0].path.clone()); - } - assert_eq!(paths[0], "game/src/y.ts", "`/` 写法同样要落成项目相对路径"); - assert_eq!( - paths[1], "game/src/y.ts", - "大小写变体同样要落成项目相对路径" - ); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 5deb4efc2..35c166d53 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -1,8 +1,10 @@ use super::*; -use axum::extract::{DefaultBodyLimit, State as AxumState}; +use axum::extract::DefaultBodyLimit; +use axum::extract::State as AxumState; use axum::http::{HeaderMap, StatusCode}; use axum::routing::post; -use axum::{Json, Router}; +use axum::Json; +use axum::Router; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::io::{BufRead, BufReader, Write}; @@ -55,6 +57,7 @@ pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool { args == [DIRECT_TOOLS_MCP_MODE_FLAG] } +#[cfg(not(test))] pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option { if !direct_tools_mcp_mode_requested(args) { return None; @@ -1984,6 +1987,7 @@ fn write_direct_tools_mcp_response( .map_err(|_| "写入 MCP 响应失败".to_string()) } +#[cfg(not(test))] fn read_direct_tools_mcp_requests( mut reader: impl BufRead, requests: tokio::sync::mpsc::Sender, String>>, @@ -2062,6 +2066,7 @@ where terminal_error.map_or(Ok(()), Err) } +#[cfg(not(test))] async fn run_direct_tools_mcp_stdio() -> Result<(), String> { let root = validate_direct_tools_project_root( &std::env::current_dir().map_err(|_| "读取 MCP 工作目录失败".to_string())?, @@ -2177,6 +2182,7 @@ pub(crate) fn stop_external_mcp_loopback() { } } +#[cfg(not(test))] #[tauri::command] pub(crate) async fn start_game_creator_external_mcp(project_path: String) -> Result { let root = Path::new(project_path.trim()); @@ -2185,6 +2191,7 @@ pub(crate) async fn start_game_creator_external_mcp(project_path: String) -> Res Ok(json!({ "url": url, "token": token, "transport": "streamable-http" })) } +#[cfg(not(test))] #[tauri::command] pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> { stop_external_mcp_loopback(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs deleted file mode 100644 index dcdc5829b..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs +++ /dev/null @@ -1,430 +0,0 @@ -//! GameAgent 对话「回合流」的采集与持久化。 -//! -//! 顺序真相放在一处:`/.agent/conversations/turn-stream.jsonl` 按**出现顺序** -//! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文 -//! 仍然来自 `tool-calls.jsonl`(同一 id 幂等合并只有一处实现)。 -//! -//! 位置稳定:每条条目的 `seq` 在**首次出现**时由观察方分配并落盘,后续更新(同一 id 的 -//! 文本追加 / 工具状态变化)只改内容不改 `seq`。因此并发落盘的先后顺序不会让"新工具插到 -//! 旧文本前面"——渲染顺序只由 `seq` 决定。 -//! -//! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。 - -use crate::agent::sanitize_detail_text; -use crate::config::write_game_creator_private_file; -use crate::project::{enforce_project_permission_policy, project_append_lock_for}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::BTreeMap; -use std::fs::File; -use std::io::{BufRead, BufReader}; -use std::path::{Path, PathBuf}; - -/// 行信封类型,与既有历史文件同构(`{"type": …, "payload": {…}}`)。 -pub(crate) const DIRECT_TURN_STREAM_RECORD_TYPE: &str = "turn_stream_item"; -/// 条目 schema 版本。 -pub(crate) const DIRECT_TURN_STREAM_SCHEMA_VERSION: &str = "agc-turn-stream.v1"; -/// 回读上限:只保留最后这么多条(按 `seq` 取最新)。 -pub(crate) const DIRECT_TURN_STREAM_LIMIT: usize = 400; -/// 单条文本段的字符上限(与工具明细同口径的截断,避免单段失控)。 -const DIRECT_TURN_STREAM_TEXT_MAX_CHARS: usize = 8000; -/// 没有流式分段时,最终回复那一段的固定 item id。 -const DIRECT_TURN_STREAM_FINAL_ITEM_ID: &str = "final"; -/// 回合失败说明那一段的固定 item id:失败说明也是这一回合的内容,排在流末尾。 -pub(crate) const DIRECT_TURN_STREAM_FAILURE_ITEM_ID: &str = "failure"; - -/// 文本段。 -pub(crate) const DIRECT_TURN_STREAM_KIND_TEXT: &str = "text"; -/// 工具调用的位置标记。 -pub(crate) const DIRECT_TURN_STREAM_KIND_TOOL: &str = "tool"; - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectTurnStreamItem { - pub(crate) schema_version: String, - /// 幂等身份:文本段 `text::`、工具 `tool::`。 - pub(crate) id: String, - pub(crate) turn_id: String, - /// `text` | `tool` - pub(crate) kind: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) text: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) call_id: Option, - /// 首次出现的写入序号:**顺序真相**,同刻按它排序。 - pub(crate) seq: u64, - /// 条目首次出现的本机毫秒时刻。 - pub(crate) at: u64, - pub(crate) updated_at: u64, -} - -#[cfg(test)] -mod snapshot_tests { - use super::*; - - fn text( - turn: &str, - id: &str, - seq: u64, - at: u64, - updated: u64, - text: &str, - ) -> DirectTurnStreamItem { - direct_turn_stream_text_item(Path::new("."), turn, id, text, seq, at, updated) - } - - #[test] - fn late_older_snapshot_cannot_undo_completed_text_or_position() { - let complete = text("turn", "item", 1, 1000, 1002, "正文"); - let late = text("turn", "item", 9, 1001, 1001, "更长但已经过期的草稿"); - let merged = normalize_stream_items(vec![complete, late]); - assert_eq!(merged.len(), 1); - assert_eq!(merged[0].text.as_deref(), Some("正文")); - assert_eq!(merged[0].seq, 1); - assert_eq!(merged[0].at, 1000); - } - - #[test] - fn retention_does_not_treat_new_turn_seq_one_as_oldest() { - let mut snapshots = (1..=DIRECT_TURN_STREAM_LIMIT) - .map(|seq| text("old", &seq.to_string(), seq as u64, 1000, 1000, "旧")) - .collect::>(); - snapshots.push(text("new", "one", 1, 2000, 2000, "新")); - let merged = normalize_stream_items(snapshots); - assert_eq!(merged.len(), DIRECT_TURN_STREAM_LIMIT); - assert_eq!(merged.last().unwrap().turn_id, "new"); - } -} - -impl DirectTurnStreamItem { - fn order_key(&self) -> (u64, u64, &str) { - (self.seq, self.at, self.id.as_str()) - } -} - -fn turn_stream_path(root: &Path) -> PathBuf { - root.join(".agent/conversations/turn-stream.jsonl") -} - -/// 文本段条目的幂等 id:同一个 Codex assistant item 只占一行。 -pub(crate) fn direct_turn_stream_text_item_id(turn_id: &str, item_id: &str) -> String { - format!("text:{}:{}", turn_id.trim(), item_id.trim()) -} - -/// 工具条目(位置标记)的幂等 id:同一个 callId 只占一行。 -pub(crate) fn direct_turn_stream_tool_item_id(turn_id: &str, call_id: &str) -> String { - format!("tool:{}:{}", turn_id.trim(), call_id.trim()) -} - -/// 构造一条文本段条目:脱敏 + 截断与 `tool-calls.jsonl` 同口径。 -pub(crate) fn direct_turn_stream_text_item( - root: &Path, - turn_id: &str, - item_id: &str, - text: &str, - seq: u64, - at: u64, - updated_at: u64, -) -> DirectTurnStreamItem { - DirectTurnStreamItem { - schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(), - id: direct_turn_stream_text_item_id(turn_id, item_id), - turn_id: turn_id.trim().to_string(), - kind: DIRECT_TURN_STREAM_KIND_TEXT.to_string(), - text: Some(sanitize_stream_text(root, text)), - call_id: None, - seq, - at, - updated_at, - } -} - -/// 构造一条工具条目:只记位置,正文仍来自 `DirectToolCall`。 -pub(crate) fn direct_turn_stream_tool_item( - turn_id: &str, - call: &crate::DirectToolCall, - seq: u64, - at: u64, -) -> DirectTurnStreamItem { - DirectTurnStreamItem { - schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(), - id: direct_turn_stream_tool_item_id(turn_id, &call.id), - turn_id: turn_id.trim().to_string(), - kind: DIRECT_TURN_STREAM_KIND_TOOL.to_string(), - text: None, - call_id: Some(call.id.trim().to_string()), - seq, - at, - updated_at: call.updated_at, - } -} - -/// 文本脱敏 + 截断:与 `tool-calls.jsonl` 同一套 `sanitize_detail_text`。 -pub(crate) fn sanitize_stream_text(root: &Path, text: &str) -> String { - let sanitized = sanitize_detail_text(root, text); - if sanitized.chars().count() <= DIRECT_TURN_STREAM_TEXT_MAX_CHARS { - return sanitized; - } - let mut truncated = sanitized - .chars() - .take(DIRECT_TURN_STREAM_TEXT_MAX_CHARS) - .collect::(); - truncated.push('…'); - truncated -} - -fn record_line(item: &DirectTurnStreamItem) -> Result { - serde_json::to_string(&serde_json::json!({ - "type": DIRECT_TURN_STREAM_RECORD_TYPE, - "payload": item, - })) - .map_err(|error| format!("序列化回合流条目失败:{error}")) -} - -/// 解析一行信封;坏行 / 非本文件条目都返回 `None`(尽力而为的展示数据,不整体失败)。 -fn stream_item_from_line(line: &str) -> Option { - let trimmed = line.trim(); - if trimmed.is_empty() { - return None; - } - let parsed: Value = serde_json::from_str(trimmed).ok()?; - if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_TURN_STREAM_RECORD_TYPE) { - return None; - } - let payload = parsed.get("payload")?; - let mut item: DirectTurnStreamItem = serde_json::from_value(payload.clone()).ok()?; - if item.id.trim().is_empty() || item.turn_id.trim().is_empty() { - return None; - } - if !matches!( - item.kind.as_str(), - DIRECT_TURN_STREAM_KIND_TEXT | DIRECT_TURN_STREAM_KIND_TOOL - ) { - return None; - } - if item.schema_version.trim().is_empty() { - item.schema_version = DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(); - } - Some(item) -} - -fn read_stream_lines(path: &Path) -> Vec { - let Ok(file) = File::open(path) else { - return Vec::new(); - }; - let mut reader = BufReader::new(file); - let mut buffer = Vec::new(); - let mut items = Vec::new(); - loop { - buffer.clear(); - match reader.read_until(b'\n', &mut buffer) { - Ok(0) => break, - // 单行解码失败(非法 UTF-8)只跳过这一行,继续读后面的行。 - Ok(_) => match std::str::from_utf8(&buffer) { - Ok(line) => { - if let Some(item) = stream_item_from_line(line) { - items.push(item); - } - } - Err(_) => continue, - }, - // 读 I/O 错误:无法再定位下一行边界,停止读取(已读到的照常返回)。 - Err(_) => break, - } - } - items -} - -/// 同一 id 的重复行合并:`seq` 取最早(位置钉死,后到的不得回退),`at` 取最早非零, -/// `updated_at` 取最大;文本只在更新(或同刻更长)的快照上替换。 -fn merge_stream_snapshot( - existing: &DirectTurnStreamItem, - incoming: &DirectTurnStreamItem, -) -> DirectTurnStreamItem { - let text_len = |item: &DirectTurnStreamItem| { - item.text - .as_deref() - .map(str::chars) - .map(Iterator::count) - .unwrap_or_default() - }; - // writer 保证更新时间单调;完成快照可以纠正正文,旧快照不能靠更长抢回所有权。 - let take_incoming = incoming.updated_at > existing.updated_at - || (incoming.updated_at == existing.updated_at && text_len(incoming) > text_len(existing)); - let mut merged = existing.clone(); - if take_incoming { - merged.text = incoming.text.clone(); - } - merged.updated_at = merged.updated_at.max(incoming.updated_at); - if merged.call_id.is_none() { - merged.call_id = incoming.call_id.clone(); - } - merged.seq = merged.seq.min(incoming.seq); - merged.at = [merged.at, incoming.at] - .into_iter() - .filter(|at| *at > 0) - .min() - .unwrap_or_default(); - merged -} - -/// 按身份归并;跨回合按起点,回合内按 seq,不能用局部 seq 判断全局新旧。 -fn normalize_stream_items(items: Vec) -> Vec { - let mut by_id: BTreeMap = BTreeMap::new(); - for item in items { - let merged = match by_id.remove(&item.id) { - Some(previous) => merge_stream_snapshot(&previous, &item), - None => item, - }; - by_id.insert(merged.id.clone(), merged); - } - let mut normalized = by_id.into_values().collect::>(); - let mut turn_starts = BTreeMap::::new(); - for item in &normalized { - turn_starts - .entry(item.turn_id.clone()) - .and_modify(|at| *at = (*at).min(item.at)) - .or_insert(item.at); - } - normalized.sort_by(|left, right| { - (turn_starts[&left.turn_id], &left.turn_id, left.order_key()).cmp(&( - turn_starts[&right.turn_id], - &right.turn_id, - right.order_key(), - )) - }); - if normalized.len() > DIRECT_TURN_STREAM_LIMIT { - normalized.drain(..normalized.len() - DIRECT_TURN_STREAM_LIMIT); - } - normalized -} - -/// 锁内读改写:整文件重写(追加与就地更新混用,没有纯追加的 JSONL 语义)。 -/// 文件规模由 400 条上限与 8000 字符截断兜住。 -fn with_locked_stream_items( - root: &Path, - mutate: impl FnOnce(&mut Vec) -> T, -) -> Result { - let path = turn_stream_path(root); - let _project_lock = crate::acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "conversation.write", - )?; - let lock = project_append_lock_for(&path)?; - let _append_guard = lock.lock("回合流写入")?; - let mut items = read_stream_lines(&path); - let outcome = mutate(&mut items); - let normalized = normalize_stream_items(items); - let mut body = String::new(); - for item in &normalized { - body.push_str(&record_line(item)?); - body.push('\n'); - } - write_game_creator_private_file(&path, body.as_bytes(), "回合流历史")?; - Ok(outcome) -} - -/// 幂等 upsert 一条回合流条目。 -/// -/// 位置(`seq` / `at`)只在第一次出现时确定:同一 id 的后续快照不得回退位置, -/// 也不得把已经写下的文本改短(并发落盘下"后到的旧快照"不会覆盖新快照)。 -pub(crate) fn upsert_direct_turn_stream_item_at( - root: &Path, - item: &DirectTurnStreamItem, -) -> Result<(), String> { - enforce_project_permission_policy(root, "conversation.write")?; - with_locked_stream_items(root, |items| { - // normalize_stream_items 在锁内归并全部版本;不得提前删除比较基准。 - items.push(item.clone()); - }) -} - -/// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。 -/// -/// 幂等:同一 `(turnId, itemId)` 已经存在时只更新文本与 `updatedAt`(回合重放 / 重复收尾 -/// 不会多出一段)。返回写下的那一条,调用方用它下发同一份快照。 -pub(crate) fn append_direct_turn_stream_text_at( - root: &Path, - turn_id: &str, - item_id: &str, - text: &str, -) -> Result, String> { - let turn_id = turn_id.trim(); - let text = text.trim(); - if turn_id.is_empty() || text.is_empty() { - return Ok(None); - } - let sanitized = sanitize_stream_text(root, text); - let item_id = item_id.trim(); - enforce_project_permission_policy(root, "conversation.write")?; - let now = crate::agent::direct_tool_call_now_ms(); - with_locked_stream_items(root, |items| { - let existing_id = direct_turn_stream_text_item_id(turn_id, item_id); - if let Some(existing) = items.iter_mut().find(|item| item.id == existing_id) { - // 位置不动:只替换文本与 updatedAt。 - existing.text = Some(sanitized.clone()); - existing.updated_at = now.max(existing.updated_at); - return Some(existing.clone()); - } - // 首次出现:位置钉在末尾(当前最大 seq + 1)。 - let next_seq = items.iter().map(|item| item.seq).max().unwrap_or(0) + 1; - let item = DirectTurnStreamItem { - schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(), - id: existing_id, - turn_id: turn_id.to_string(), - kind: DIRECT_TURN_STREAM_KIND_TEXT.to_string(), - text: Some(sanitized), - call_id: None, - seq: next_seq, - at: now, - updated_at: now, - }; - items.push(item.clone()); - Some(item) - }) -} - -/// 没有任何 item 文本时补最终回复;已有 item 由完成事件负责,不能猜测覆盖某一段。 -pub(crate) fn finalize_direct_turn_stream_reply_at( - root: &Path, - turn_id: &str, - visible_reply: &str, -) -> Result, String> { - let turn_id = turn_id.trim(); - if turn_id.is_empty() || visible_reply.trim().is_empty() { - return Ok(None); - } - // 入口再做一次可见性投影:调用方给的是原始回复时,思考块不能落进对话流。 - let visible_reply = crate::agent::project_direct_codex_visible_text(visible_reply) - .unwrap_or_else(|| visible_reply.trim().to_string()); - let visible_reply = visible_reply.as_str(); - enforce_project_permission_policy(root, "conversation.write")?; - let now = crate::agent::direct_tool_call_now_ms(); - with_locked_stream_items(root, |items| { - if items - .iter() - .any(|item| item.turn_id == turn_id && item.kind == DIRECT_TURN_STREAM_KIND_TEXT) - { - None - } else { - let next_seq = items - .iter() - .filter(|item| item.turn_id == turn_id) - .map(|item| item.seq) - .max() - .unwrap_or(0) - + 1; - let item = direct_turn_stream_text_item( - root, - turn_id, - DIRECT_TURN_STREAM_FINAL_ITEM_ID, - visible_reply, - next_seq, - now, - now, - ); - items.push(item.clone()); - Some(item) - } - }) -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index fb384fe19..46bbbb771 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -48,7 +48,6 @@ pub(in crate::agent) use external_generation_state::{ }; #[cfg(test)] pub(crate) use external_generation_state::{ - platform_art_generation_external_configuration_fingerprint, platform_art_generation_legacy_external_configuration_fingerprint, setup_platform_art_generation_runtime_accepted_for_recovery_test, write_platform_art_generation_runtime_accepted_for_test, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs index 72fa03629..ff5f56d55 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs @@ -6,6 +6,7 @@ pub(crate) struct AgentProgressEmitter<'a> { } impl<'a> AgentProgressEmitter<'a> { + #[cfg(not(test))] pub(crate) fn new(app: &'a tauri::AppHandle, project_path: &str) -> Self { Self { app, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 3647d5979..7e3327881 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -817,24 +817,17 @@ mod tests { use super::{ acquire_game_creator_agent_provider_plan_project_write_lock_with_wait, agent_runtime_root_source_at, bind_game_creator_agent_runtime_run_profile_at, - build_game_creator_agent_background_final_reply_request, build_game_creator_agent_background_tool_plan_request_locked, - game_creator_agent_context_preload_notice, - game_creator_agent_runtime_run_profile_binding_path, - game_creator_project_supervisor_chat_system_prompt, init_local_game_project_at, - new_game_creation_app_seed_tasks, provider_command_exec_contract, - provider_command_start_contract, + game_creator_agent_context_preload_notice, init_local_game_project_at, + new_game_creation_app_seed_tasks, render_relaxed_autonomous_manifest_ready_task_background_prompt, - required_runtime_prompt_section, start_game_creator_agent_runtime_task_at, - AgentRuntimeGoalContractAcceptanceNodeDraft, AgentRuntimeGoalContractDraft, - AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan, + start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft, + AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlanRequestSnapshot, GameCreatorLlmConfig, - AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT, - AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME, - AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_RUN_PROFILE_STANDARD, - AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT, AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, }; fn build_game_creator_agent_background_tool_plan_request_for_test( @@ -871,24 +864,6 @@ mod tests { ) } - fn native_input_required_fields( - request: &platform_llm::LlmRunRequest, - tool: &str, - ) -> Vec { - let function_name = crate::agent_native_tools::native_runtime_function_name(tool) - .expect("native runtime function name"); - request - .function_tools - .iter() - .find(|function| function.name == function_name) - .and_then(|function| function.parameters.pointer("/properties/input/required")) - .and_then(serde_json::Value::as_array) - .expect("native input required fields") - .iter() - .map(|field| field.as_str().expect("required field string").to_string()) - .collect() - } - #[test] fn relaxed_request_keeps_general_catalog_after_plan_rejection() { let directory = crate::tests::canonical_test_tempdir("provider-plan-rejection-repair-"); @@ -1437,20 +1412,6 @@ mod tests { assert!(!prompt.contains("本轮唯一可用工具是 agent.goal_contract")); } - fn completion_blocker_protocol_requires_tool_repair_before_response() { - let protocol = AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL; - - assert!(protocol.contains("最新 observation")); - assert!(protocol.contains("runtime.autonomous_completion")); - assert!(protocol.contains("status 为 blocked")); - assert!(protocol.contains("才能调用 respond_to_user")); - assert!(!protocol.contains("legacy response")); - assert!(protocol.contains("observation.detail 的 nextRequiredAction")); - assert!(protocol.contains("读取、修复和验证工具")); - assert!(protocol.contains("完成要求的动作、取得后续可信 observation")); - assert!(protocol.contains("通过完成门禁后")); - } - #[test] fn supervisor_request_snapshot_preserves_prompt_visible_running_sibling_after_manifest_failure() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index fffdc5158..17441b11c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -297,18 +297,18 @@ pub(crate) use entrypoints::{ generate_local_game_draft_at, read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at, register_game_creator_manifest_invalidation_event_sink, + validate_game_creator_manifest_invalidation_event_sink, +}; +#[cfg(not(test))] +pub(crate) use entrypoints::{ set_game_creator_agent_runtime_update_app_handle, start_game_creator_manifest_invalidation_event_sink, - validate_game_creator_manifest_invalidation_event_sink, }; #[cfg(test)] pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at; pub(crate) use finalization::AgentRuntimePendingActionResume; #[cfg(test)] -pub(crate) use interaction::{ - acquire_game_creator_agent_runtime_user_input_answer_locks_for_test, - agent_runtime_tool_requires_repository_context_fingerprint_gate, -}; +pub(crate) use interaction::agent_runtime_tool_requires_repository_context_fingerprint_gate; pub(crate) use interaction::{ answer_game_creator_agent_runtime_user_input_at, confirm_game_creator_agent_runtime_task_at, pending_repository_context_drift_observation, reject_game_creator_agent_runtime_task_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 10dd259f3..be807ca6c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -17,6 +17,7 @@ fn lock_game_creator_manifest_invalidation_event_sinks( .unwrap_or_else(|poisoned| poisoned.into_inner()) } +#[cfg(not(test))] pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHandle) { let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app); } @@ -60,7 +61,6 @@ pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, messag #[derive(Clone)] pub(crate) struct DirectGameCreatorTurnUpdateEmitter { - project_path: String, /// Thread Manager 的线程身份:进度只回填到"这一轮仍被占用"的那一格上。 thread_id: String, turn_id: String, @@ -70,74 +70,14 @@ pub(crate) struct DirectGameCreatorTurnUpdateEmitter { impl DirectGameCreatorTurnUpdateEmitter { pub(crate) fn new(root: &Path, turn_id: String) -> Self { Self { - project_path: root.to_string_lossy().into_owned(), thread_id: crate::agent::thread_id_for_project(root), turn_id, sequence: Arc::new(AtomicU64::new(0)), } } - pub(crate) fn emit( - &self, - status: &'static str, - activity: Option<&'static str>, - accumulated_text: Option, - tool_calls: Option>, - ) { - self.emit_with_reasoning(status, activity, accumulated_text, tool_calls, None); - } - - /// 带思考过程的回合更新:`reasoning_text` 为"当前累计的思考全文"(前端整段替换)。 - pub(crate) fn emit_with_reasoning( - &self, - status: &'static str, - activity: Option<&'static str>, - accumulated_text: Option, - tool_calls: Option>, - reasoning_text: Option, - ) { - self.emit_full( - status, - activity, - accumulated_text, - tool_calls, - reasoning_text, - Vec::new(), - ); - } - - /// 带回合流的回合更新:`stream_items` 是"顺序真相"里本次变化的那几条。 - /// - /// 前端按这些条目的 `seq` 顺序渲染,所以它们必须来自与落盘同一份数据, - /// 不能在前端各算一套顺序。 - pub(crate) fn emit_with_stream_items( - &self, - status: &'static str, - activity: Option<&'static str>, - accumulated_text: Option, - tool_calls: Option>, - stream_items: Vec, - ) { - self.emit_full( - status, - activity, - accumulated_text, - tool_calls, - None, - stream_items, - ); - } - - #[allow(clippy::too_many_arguments)] - fn emit_full( - &self, - status: &'static str, - activity: Option<&'static str>, - accumulated_text: Option, - tool_calls: Option>, - reasoning_text: Option, - stream_items: Vec, - ) { + /// 回填这一轮逻辑回合的进度(状态 / 活动 / 序号);首页「运行中的项目」快照读它。 + pub(crate) fn emit(&self, status: &'static str, activity: Option<&'static str>) { let status_is_allowed = matches!( status, "accepted" | "running" | "streaming" | "finalizing" | "completed" | "failed" @@ -154,6 +94,8 @@ impl DirectGameCreatorTurnUpdateEmitter { | "controlled-tool" | "web-search" | "context-compaction" + | "host-review" + | "error-feedback" | "response-finalization" | "none" ) @@ -163,11 +105,7 @@ impl DirectGameCreatorTurnUpdateEmitter { return; } let sequence = self.sequence.fetch_add(1, Ordering::AcqRel) + 1; - let updated_at = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .min(u64::MAX as u128) as u64; + let updated_at = crate::agent::direct_now_ms(); update_active_turn( &self.thread_id, &self.turn_id, @@ -176,24 +114,6 @@ impl DirectGameCreatorTurnUpdateEmitter { sequence, updated_at, ); - let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { - return; - }; - let _ = app.emit( - "game-creator-direct-turn-update", - GameCreatorDirectTurnUpdateEvent { - project_path: self.project_path.clone(), - turn_id: self.turn_id.clone(), - sequence, - status: status.to_string(), - activity: activity.map(str::to_string), - accumulated_text, - tool_calls, - reasoning_text, - stream_items: (!stream_items.is_empty()).then_some(stream_items), - updated_at, - }, - ); } pub(crate) fn turn_id(&self) -> &str { @@ -201,6 +121,7 @@ impl DirectGameCreatorTurnUpdateEmitter { } } +#[cfg(not(test))] pub(crate) fn start_game_creator_manifest_invalidation_event_sink( app: tauri::AppHandle, ) -> Result { @@ -1015,3 +936,33 @@ pub(crate) fn read_game_creator_agent_runtimes_at( .map(|agent_id| read_game_creator_agent_runtime_at(root, &agent_id)) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + /// DirectRuntime 实际传过的活动值必须都在 `emit` 白名单里:漏一个会在 debug 构建 panic、 + /// 在 release 构建静默丢掉整次进度回填(首页「运行中的项目」不再随这一轮更新)。 + #[test] + fn emit_accepts_every_activity_the_direct_runtime_passes() { + let root = std::env::temp_dir().join("agc-emit-activity-guard"); + let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, "turn-guard".to_string()); + for activity in [ + "request-accepted", + "preparing", + "file-read", + "file-write", + "game-verify", + "command-exec", + "controlled-tool", + "web-search", + "context-compaction", + "host-review", + "error-feedback", + "response-finalization", + "none", + ] { + emitter.emit("running", Some(activity)); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs index a91344eda..d1f2aa58e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs @@ -477,20 +477,6 @@ fn resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks( Ok((None, runtime_lock, resolved)) } -#[cfg(test)] -pub(crate) fn acquire_game_creator_agent_runtime_user_input_answer_locks_for_test( - root: &Path, - agent_id: &str, - run_id: &str, - action_id: &str, -) -> Result<(Option, AgentRuntimeTaskLock), String> { - let (project_lock, runtime_lock, _) = - resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks( - root, agent_id, run_id, action_id, - )?; - Ok((project_lock, runtime_lock)) -} - pub(crate) fn answer_game_creator_agent_runtime_user_input_at( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index b889baafd..c0a90b032 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -89,6 +89,8 @@ pub(crate) use run_configuration::{ game_creator_agent_runtime_project_revision_path, game_creator_agent_runtime_run_profile_binding_path, }; +#[cfg(not(test))] +pub(crate) use steering::game_creator_agent_runtime_provider_request_count_for_roots; #[cfg(test)] pub(crate) use steering::{ acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait, @@ -96,7 +98,6 @@ pub(crate) use steering::{ }; pub(crate) use steering::{ consume_game_creator_agent_runtime_steers, - game_creator_agent_runtime_provider_request_count_for_roots, interrupt_game_creator_agent_runtime_provider_request_at, interrupt_game_creator_agent_runtime_provider_requests_for_roots, register_game_creator_agent_runtime_provider_request, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 3618ca475..57a094da1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -11943,11 +11943,6 @@ impl ExternalGameplayJavascript { .iter() .any(|unit| unit.to_ascii_lowercase().contains(&marker)) } - - #[cfg(test)] - pub(in crate::agent) fn module_units(&self) -> &[String] { - &self.module_units - } } fn normalize_javascript_module_analysis_sources( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 9d8648eb0..0a15b0460 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -44,9 +44,7 @@ pub(in crate::agent) use ui_design_doc::*; #[cfg(test)] pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked; #[cfg(test)] -pub(crate) use delivery::{ - build_static_delegate_result_for_child_at, wake_waiting_static_delegate_parent_run_for_test_at, -}; +pub(crate) use delivery::build_static_delegate_result_for_child_at; #[cfg(test)] pub(crate) use isolated_joins::render_isolated_join_status_batch_with_limit; #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index 4e29d3356..709023e9d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -408,14 +408,6 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( Ok(true) } -#[cfg(test)] -pub(crate) fn wake_waiting_static_delegate_parent_run_for_test_at( - root: &Path, - parent_task: &AgentRuntimeTaskRecord, -) -> Result { - wake_waiting_static_delegate_parent_run_at(root, parent_task) -} - pub(in crate::agent) fn wake_waiting_autonomous_manifest_parent_run_at( root: &Path, parent_task: &AgentRuntimeTaskRecord, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs index 22e016807..5eabb23b6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -17,7 +17,7 @@ use std::path::{Path, PathBuf}; use crate::agent::PendingTurn; use crate::agent::{ append_direct_project_user_message_at, claim_pending_turn, complete_turn_if_reserved, - direct_codex_user_item_to_prompt, direct_tool_call_now_ms, redact_agent_runtime_error, + direct_codex_user_item_to_prompt, direct_now_ms, redact_agent_runtime_error, run_direct_game_creator_turn_at_with_creation_type_and_emitter, thread_id_for_project, DirectGameCreatorTurnUpdateEmitter, DirectTurnError, DirectTurnTerminal, DispatchedTurn, }; @@ -55,7 +55,7 @@ impl TurnReservation { complete_turn_if_reserved( &self.thread_id, &self.token, - terminal.event(direct_tool_call_now_ms(), self.user_item_id.as_deref()), + terminal.event(direct_now_ms(), self.user_item_id.as_deref()), ) } @@ -72,7 +72,7 @@ impl TurnReservation { })) .expect("canonical user item"), None, - direct_tool_call_now_ms(), + direct_now_ms(), ); super::enqueue_pending_turn(thread_id, pending).expect("enqueue test turn"); let dispatched = claim_pending_turn(thread_id).expect("claim test turn"); @@ -160,9 +160,9 @@ async fn run_dispatched_direct_turn( ) .await; match outcome { - Ok(reply) => { + Ok(_) => { // 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。 - emitter.emit("completed", Some("none"), Some(reply), None); + emitter.emit("completed", Some("none")); } Err(error) => { // 放行之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。 @@ -211,7 +211,7 @@ mod tests { })) .expect("canonical user item"), None, - direct_tool_call_now_ms(), + direct_now_ms(), ) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs index 0055d42e5..b9ee45c96 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs @@ -17,9 +17,9 @@ use std::sync::{Mutex, OnceLock}; use uuid::Uuid; use crate::agent::{ - direct_codex_user_item_id_for_client_turn_id, direct_tool_call_now_ms, queue_has_room, - ConsumeResult, EnqueueOutcome, EnqueueRejection, PendingTurn, QueueRemovalOutcome, - QueueRemovalReason, SubscriptionBootstrap, ThreadEvent, + direct_codex_user_item_id_for_client_turn_id, direct_now_ms, queue_has_room, ConsumeResult, + EnqueueOutcome, EnqueueRejection, PendingTurn, QueueRemovalOutcome, QueueRemovalReason, + SubscriptionBootstrap, ThreadEvent, }; const DEFAULT_MAX_EVENTS: usize = 8_192; @@ -854,7 +854,7 @@ pub(crate) fn remove_pending_turn(thread_id: &str, client_turn_id: &str) -> Queu global_thread_manager() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove_pending_turn(thread_id, client_turn_id, direct_tool_call_now_ms()) + .remove_pending_turn(thread_id, client_turn_id, direct_now_ms()) }; if outcome == QueueRemovalOutcome::Removed { notify_subscribers(thread_id); @@ -873,7 +873,7 @@ pub(crate) fn claim_pending_turn(thread_id: &str) -> Option { global_thread_manager() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .claim_pending_turn(thread_id, direct_tool_call_now_ms()) + .claim_pending_turn(thread_id, direct_now_ms()) }; if claimed.is_some() { notify_subscribers(thread_id); @@ -954,7 +954,7 @@ pub(crate) fn release_stale_direct_turn( thread_id, expected_client_turn_id, min_age_ms, - direct_tool_call_now_ms(), + direct_now_ms(), ) }; if matches!(outcome, StaleTurnRelease::Released(_)) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs index f5d328482..9bab8b252 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs @@ -21,6 +21,15 @@ use serde_json::Value; use std::path::Path; use ts_rs::TS; +/// 宿主观测时刻:Unix 毫秒。 +pub(crate) fn direct_now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64 +} + /// 正文(消息 / 思考)上限。 const THREAD_TEXT_MAX_CHARS: usize = 8_000; /// 工具明细(命令 / 参数 / 输出)上限。 @@ -676,7 +685,7 @@ fn json_ms(container: &Value, key: &str) -> Option { /// `item/started` / `item/completed` 的事件级阶段时间(毫秒)。 /// /// 字段位置按当前 app-server 协议:通知层带 `params.startedAtMs` / `params.completedAtMs`, -/// 条目自带时用条目里的同名毫秒字段(`direct_tool_calls` 读的是同一处)。完成事件即使同时 +/// 条目自带时用条目里的同名毫秒字段。完成事件即使同时 /// 带着开始字段也只取**完成**时间;两者都没有、但 `durationMs` 有可靠起点时按 /// 起点 + 时长派生结束。都没有就用宿主处理该事件的钟——原生缺阶段时间时这是唯一诚实的值。 pub(crate) fn thread_item_event_at_ms( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 8b24b0cfc..8262ccb0c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -13,8 +13,6 @@ use crate::agent::{ AgentRuntimeToolPlan, AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT, AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PLAN_STEP_LIMIT, }; -#[cfg(test)] -use crate::GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; pub(crate) const AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME: &str = "update_agent_plan"; pub(crate) const AGENT_RUNTIME_RESPOND_FUNCTION_NAME: &str = "respond_to_user"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/goal.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/goal.rs index c01db32bd..546d01e81 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/analytics/goal.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/goal.rs @@ -114,19 +114,10 @@ pub(crate) fn created(writer: Option<&AnalyticsWriter>, root: &Path, project_id: } } -pub(super) fn retry_pending(writer: &AnalyticsWriter) { - let projects: Vec<_> = pending() - .lock() - .unwrap_or_else(|error| error.into_inner()) - .projects - .iter() - .cloned() - .collect(); - for (root, project_id) in projects { - // 投递失败不移除资格;后续真实受理仍可重试。 - writer.try_goal(Request::Created { root, project_id }); - } -} +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(super) use desktop::retry_pending; // 调用方已取得项目独立埋点锁;不得在文件 I/O 期间持有 pending 内存锁。 fn initialize_pending(root: &Path, project_id: &str, path: &Path) -> Result<(), String> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/goal/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/goal/desktop.rs new file mode 100644 index 000000000..200fbf448 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/goal/desktop.rs @@ -0,0 +1,15 @@ +use super::{pending, AnalyticsWriter, Request}; + +pub(crate) fn retry_pending(writer: &AnalyticsWriter) { + let projects: Vec<_> = pending() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .projects + .iter() + .cloned() + .collect(); + for (root, project_id) in projects { + // 投递失败不移除资格;后续真实受理仍可重试。 + writer.try_goal(Request::Created { root, project_id }); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs index df4800ce8..82383bf6c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs @@ -3,12 +3,15 @@ use super::contract::*; use super::session::{LifecycleState, SessionRecord}; use super::store::AnalyticsWriter; use std::collections::HashMap; -use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use std::time::Instant; -use tauri::Manager; use uuid::Uuid; +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::*; + struct OpenProject { id: String, path: String, @@ -44,8 +47,6 @@ struct Service { } static GUI: OnceLock = OnceLock::new(); -// 仅串行化发布服务与退出;后台等待认证状态时不占此锁。 -static EXITING: Mutex = Mutex::new(false); #[cfg(test)] pub(crate) struct LifecycleFixture(GuiState); @@ -373,50 +374,6 @@ fn elapsed_ms(start: Instant) -> u64 { start.elapsed().as_millis().min(9_007_199_254_740_991) as u64 } -pub(crate) fn initialize(app: tauri::AppHandle, config_dir: PathBuf, version: String) { - tauri::async_runtime::spawn_blocking(move || { - crate::platform_session::initialize_analytics_identity(|route, sequence| { - let Ok(exiting) = EXITING.lock() else { - return; - }; - if !*exiting { - initialize_with_identity(config_dir, version, route, sequence); - } - }); - if let Some(gui) = GUI.get() { - super::goal::retry_pending(&gui.writer); - } - let handle = app.clone(); - let _ = app.run_on_main_thread(move || observe_windows(&handle, None, false, true)); - }); -} - -fn initialize_with_identity(config_dir: PathBuf, version: String, route: Route, sequence: u64) { - if GUI.get().is_some() { - return; - } - let session_id = Uuid::new_v4().to_string(); - let writer = AnalyticsWriter::start(config_dir.clone(), session_id.clone()); - let context = Context { - route, - editor_session_id: session_id.clone(), - client_version: version.clone(), - }; - let state = GuiState::new(context, sequence, writer.clone()); - if GUI - .set(Service { - session_id, - version, - writer, - state: Mutex::new(state), - }) - .is_ok() - { - with_state(|state| state.start()); - super::upload::start(config_dir); - } -} - fn with_state(f: impl FnOnce(&mut GuiState) -> T) -> Option { let gui = GUI.get()?; // 临界区只更新内存并 try_send,不做磁盘/网络操作,也不重新取得业务锁。 @@ -480,164 +437,6 @@ pub(crate) fn created( } } -#[tauri::command] -pub(crate) async fn record_analytics_project_open( - window: tauri::Window, - context: Context, - project_path: String, - operation_id: String, - open_source: OpenSource, - event_time: String, -) { - if !valid_context(&context) - || Uuid::parse_str(&operation_id).is_err() - || project_path.len() > 32768 - || !valid_time(&event_time) - { - return; - } - let label = window.label().to_string(); - if with_state(|state| { - state.reserve_open(&label, &operation_id, &project_path, &event_time); - }) - .is_none() - { - return; - } - // 目录/manifest 读取不占用 GUI 线程,也不会把读失败返回给业务操作。 - let _ = tauri::async_runtime::spawn_blocking(move || { - let Ok(root) = crate::validated_local_project_directory_path(&project_path) else { - return; - }; - let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else { - return; - }; - let project = OpenProject { - id: manifest.project_id, - path: project_path, - operation_id, - }; - with_state(|state| state.project_opened(label, context, project, open_source, event_time)); - }) - .await; -} - -#[tauri::command] -pub(crate) fn record_analytics_project_leave(window: tauri::Window, project_path: String) { - with_state(|state| state.leave(window.label(), Some(&project_path))); -} - -#[tauri::command] -pub(crate) async fn record_analytics_ui_save( - context: Context, - project_path: String, - operation_id: String, - save_source: SaveSource, - changed: bool, - event_time: String, -) { - if !valid_context(&context) - || Uuid::parse_str(&operation_id).is_err() - || project_path.len() > 32768 - || !valid_time(&event_time) - || !matches!(save_source, SaveSource::Manual | SaveSource::Auto) - || (save_source == SaveSource::Auto && !changed) - { - return; - } - let Some(gui) = GUI.get() else { return }; - let writer = gui.writer.clone(); - let _ = tauri::async_runtime::spawn_blocking(move || { - let Ok(root) = crate::validated_local_project_directory_path(&project_path) else { - return; - }; - let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else { - return; - }; - super::project::saved( - Some((context, writer)), - &manifest.project_id, - Source::UiEditor, - &operation_id, - ProjectSaved { - save_source, - revision_id: None, - }, - Some(&event_time), - ); - }) - .await; -} - -pub(crate) fn mark_restart() { - with_state(|state| state.restart = true); -} - -pub(crate) fn page_loading(window: &str) { - with_state(|state| state.leave(window, None)); -} - -pub(crate) fn exit() { - if let Ok(mut exiting) = EXITING.lock() { - *exiting = true; - } - with_state(GuiState::exit); -} - -pub(crate) fn observe_windows( - app: &tauri::AppHandle, - excluded: Option<&str>, - minimized: bool, - initial: bool, -) { - if GUI.get().is_none() { - return; - } - let mut focused = None; - let mut unknown = false; - for (label, window) in app.webview_windows() { - if excluded == Some(label.as_str()) { - continue; - } - match ( - window.is_focused(), - window.is_minimized(), - window.is_visible(), - ) { - (Ok(true), Ok(false), Ok(true)) => { - focused = Some(label); - break; - } - (Ok(_), Ok(_), Ok(_)) => {} - _ => unknown = true, - } - } - if focused.is_none() && unknown { - return; - } - with_state(|state| state.windows(focused, minimized, initial)); -} - -pub(crate) fn window_event(window: &tauri::Window, event: &tauri::WindowEvent) { - let destroyed = matches!(event, tauri::WindowEvent::Destroyed); - if destroyed { - with_state(|state| state.leave(window.label(), None)); - } - if destroyed - || matches!( - event, - tauri::WindowEvent::Focused(_) | tauri::WindowEvent::Resized(_) - ) - { - observe_windows( - window.app_handle(), - destroyed.then_some(window.label()), - window.is_minimized().unwrap_or(false), - false, - ); - } -} - #[cfg(test)] #[path = "gui_tests.rs"] mod tests; diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/gui/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui/desktop.rs new file mode 100644 index 000000000..e81bb5de2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui/desktop.rs @@ -0,0 +1,208 @@ +use super::*; +use std::path::PathBuf; +use tauri::Manager; + +// 仅串行化发布服务与退出;后台等待认证状态时不占此锁。 +static EXITING: Mutex = Mutex::new(false); + +pub(crate) fn initialize(app: tauri::AppHandle, config_dir: PathBuf, version: String) { + tauri::async_runtime::spawn_blocking(move || { + crate::platform_session::initialize_analytics_identity(|route, sequence| { + let Ok(exiting) = EXITING.lock() else { + return; + }; + if !*exiting { + initialize_with_identity(config_dir, version, route, sequence); + } + }); + if let Some(gui) = GUI.get() { + super::super::goal::retry_pending(&gui.writer); + } + let handle = app.clone(); + let _ = app.run_on_main_thread(move || observe_windows(&handle, None, false, true)); + }); +} + +fn initialize_with_identity(config_dir: PathBuf, version: String, route: Route, sequence: u64) { + if GUI.get().is_some() { + return; + } + let session_id = Uuid::new_v4().to_string(); + let writer = AnalyticsWriter::start(config_dir.clone(), session_id.clone()); + let context = Context { + route, + editor_session_id: session_id.clone(), + client_version: version.clone(), + }; + let state = GuiState::new(context, sequence, writer.clone()); + if GUI + .set(Service { + session_id, + version, + writer, + state: Mutex::new(state), + }) + .is_ok() + { + with_state(|state| state.start()); + super::super::upload::start(config_dir); + } +} + +#[tauri::command] +pub(crate) async fn record_analytics_project_open( + window: tauri::Window, + context: Context, + project_path: String, + operation_id: String, + open_source: OpenSource, + event_time: String, +) { + if !valid_context(&context) + || Uuid::parse_str(&operation_id).is_err() + || project_path.len() > 32768 + || !valid_time(&event_time) + { + return; + } + let label = window.label().to_string(); + if with_state(|state| { + state.reserve_open(&label, &operation_id, &project_path, &event_time); + }) + .is_none() + { + return; + } + // 目录/manifest 读取不占用 GUI 线程,也不会把读失败返回给业务操作。 + let _ = tauri::async_runtime::spawn_blocking(move || { + let Ok(root) = crate::validated_local_project_directory_path(&project_path) else { + return; + }; + let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else { + return; + }; + let project = OpenProject { + id: manifest.project_id, + path: project_path, + operation_id, + }; + with_state(|state| state.project_opened(label, context, project, open_source, event_time)); + }) + .await; +} + +#[tauri::command] +pub(crate) fn record_analytics_project_leave(window: tauri::Window, project_path: String) { + with_state(|state| state.leave(window.label(), Some(&project_path))); +} + +#[tauri::command] +pub(crate) async fn record_analytics_ui_save( + context: Context, + project_path: String, + operation_id: String, + save_source: SaveSource, + changed: bool, + event_time: String, +) { + if !valid_context(&context) + || Uuid::parse_str(&operation_id).is_err() + || project_path.len() > 32768 + || !valid_time(&event_time) + || !matches!(save_source, SaveSource::Manual | SaveSource::Auto) + || (save_source == SaveSource::Auto && !changed) + { + return; + } + let Some(gui) = GUI.get() else { return }; + let writer = gui.writer.clone(); + let _ = tauri::async_runtime::spawn_blocking(move || { + let Ok(root) = crate::validated_local_project_directory_path(&project_path) else { + return; + }; + let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else { + return; + }; + super::super::project::saved( + Some((context, writer)), + &manifest.project_id, + Source::UiEditor, + &operation_id, + ProjectSaved { + save_source, + revision_id: None, + }, + Some(&event_time), + ); + }) + .await; +} + +pub(crate) fn mark_restart() { + with_state(|state| state.restart = true); +} + +pub(crate) fn page_loading(window: &str) { + with_state(|state| state.leave(window, None)); +} + +pub(crate) fn exit() { + if let Ok(mut exiting) = EXITING.lock() { + *exiting = true; + } + with_state(GuiState::exit); +} + +pub(crate) fn observe_windows( + app: &tauri::AppHandle, + excluded: Option<&str>, + minimized: bool, + initial: bool, +) { + if GUI.get().is_none() { + return; + } + let mut focused = None; + let mut unknown = false; + for (label, window) in app.webview_windows() { + if excluded == Some(label.as_str()) { + continue; + } + match ( + window.is_focused(), + window.is_minimized(), + window.is_visible(), + ) { + (Ok(true), Ok(false), Ok(true)) => { + focused = Some(label); + break; + } + (Ok(_), Ok(_), Ok(_)) => {} + _ => unknown = true, + } + } + if focused.is_none() && unknown { + return; + } + with_state(|state| state.windows(focused, minimized, initial)); +} + +pub(crate) fn window_event(window: &tauri::Window, event: &tauri::WindowEvent) { + let destroyed = matches!(event, tauri::WindowEvent::Destroyed); + if destroyed { + with_state(|state| state.leave(window.label(), None)); + } + if destroyed + || matches!( + event, + tauri::WindowEvent::Focused(_) | tauri::WindowEvent::Resized(_) + ) + { + observe_windows( + window.app_handle(), + destroyed.then_some(window.label()), + window.is_minimized().unwrap_or(false), + false, + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/upload.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/upload.rs index 8d6bda7b9..696dfdc41 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/analytics/upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/upload.rs @@ -2,7 +2,7 @@ use super::{contract::Route, store}; use crate::platform_session::{current_platform_session, PlatformSessionSnapshot}; use std::fs::{File, OpenOptions}; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; @@ -12,33 +12,17 @@ const CYCLE_BUDGET: Duration = Duration::from_secs(120); const MAX_BATCHES: usize = 20; static FAILURES: AtomicU64 = AtomicU64::new(0); +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(super) use desktop::start; + fn failure() { let _ = FAILURES.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| { Some(n.saturating_add(1)) }); } -pub(super) fn start(config_dir: PathBuf) { - tauri::async_runtime::spawn(async move { - let Ok(client) = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(REQUEST_TIMEOUT) - .build() - else { - return; - }; - let root = config_dir.join("analytics"); - let mut next = tokio::time::Instant::now() + INTERVAL; - loop { - tokio::time::sleep_until(next).await; - // 休眠恢复只跑一次,下一轮从当前时间重新计时。 - if advance_schedule(tokio::time::Instant::now(), &mut next) { - cycle(&root, &client).await; - } - } - }); -} - fn advance_schedule(now: tokio::time::Instant, next: &mut tokio::time::Instant) -> bool { if now < *next { return false; diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/upload/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/upload/desktop.rs new file mode 100644 index 000000000..ef3ef0a47 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/upload/desktop.rs @@ -0,0 +1,23 @@ +use super::{advance_schedule, cycle, INTERVAL, REQUEST_TIMEOUT}; +use std::path::PathBuf; + +pub(crate) fn start(config_dir: PathBuf) { + tauri::async_runtime::spawn(async move { + let Ok(client) = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(REQUEST_TIMEOUT) + .build() + else { + return; + }; + let root = config_dir.join("analytics"); + let mut next = tokio::time::Instant::now() + INTERVAL; + loop { + tokio::time::sleep_until(next).await; + // 休眠恢复只跑一次,下一轮从当前时间重新计时。 + if advance_schedule(tokio::time::Instant::now(), &mut next) { + cycle(&root, &client).await; + } + } + }); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index 745535122..44d7dc7a1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -25,17 +25,20 @@ use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use crate::agent::{ - generate_platform_art_asset_with_options_at, read_agent_runtime_json_sidecar_with_max_bytes, - write_agent_runtime_json_sidecar_with_max_bytes, PlatformArtAssetGenerationOptions, + read_agent_runtime_json_sidecar_with_max_bytes, write_agent_runtime_json_sidecar_with_max_bytes, }; -use crate::commands::prepare_local_project_asset_generation; use crate::project::{ enforce_project_permission_policy, prepare_local_project_audio_generation, - read_existing_manifest_for_project, run_local_project_audio_generation_at, - LocalProjectAudioGenerationRequest, LocalProjectResourceEditKind, + read_existing_manifest_for_project, LocalProjectAudioGenerationRequest, + LocalProjectResourceEditKind, }; use shared_contracts::game_creation_app::GameCreationAppAssetKind; +#[cfg(not(test))] +mod runtime; +#[cfg(not(test))] +pub(crate) use runtime::start_local_project_asset_generation; + pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1"; pub(crate) const ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH: &str = ".agent/runtime/asset-generation-tasks/tasks.json"; @@ -45,7 +48,6 @@ pub(crate) const ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS: usize = 50; pub(crate) const ASSET_GENERATION_TASK_ID_MAX_CHARS: usize = 128; pub(crate) const ASSET_GENERATION_TASK_STATUS_QUEUED: &str = "queued"; -pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running"; pub(crate) const ASSET_GENERATION_TASK_STATUS_COMPLETED: &str = "completed"; pub(crate) const ASSET_GENERATION_TASK_STATUS_FAILED: &str = "failed"; pub(crate) const ASSET_GENERATION_TASK_CHANGED_EVENT: &str = @@ -72,7 +74,6 @@ fn emit_asset_generation_task_changed(root: &Path, task_id: &str) { } const ASSET_GENERATION_TASK_PHASE_QUEUED: &str = "排队中。"; -const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。"; const ASSET_GENERATION_TASK_PHASE_COMPLETED: &str = "生成已完成。"; /// 中断收口:清单里已经能查到这次请求的目标素材 → 按事实收口为已完成。 const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED: &str = @@ -89,9 +90,6 @@ const ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR: &str = "应用退出时生成任务仍在进行,目标素材未登记"; const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str = "应用退出时生成任务仍在进行,未能在清单里确认结果"; -/// 音频任务收口:通道跑完但没有登记出素材(`derive` 在有源 / 无源两条路上都必须登记 assets)。 -const ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR: &str = "生成完成但未登记素材"; - /// 一条生成任务的权威记录。字段名与前端一一对应(camelCase)。 #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] @@ -387,57 +385,6 @@ fn register_live_task_id(task_id: &str) -> bool { .unwrap_or(false) } -/// 后台执行:状态与阶段文案的每一次流转都由这里写账本。 -async fn run_local_project_asset_generation_task( - root: PathBuf, - task_id: String, - prompt: String, - options: PlatformArtAssetGenerationOptions, -) { - if update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); - task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string(); - task.started_at_millis = Some(now_millis()); - }) - .is_err() - { - remove_live_task_id(&task_id); - return; - } - let outcome = generate_platform_art_asset_with_options_at(&root, &prompt, &[], &options).await; - match outcome { - Ok(generated) => { - let _ = update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); - task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); - task.asset_id = Some(generated.asset.id.clone()); - task.finished_at_millis = Some(now_millis()); - task.error = None; - }); - } - Err(error) => { - let _ = update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); - task.phase_detail = format!("生成失败:{error}"); - task.error = Some(error.clone()); - task.finished_at_millis = Some(now_millis()); - }); - } - } - remove_live_task_id(&task_id); -} - -/// 走音频无源生成链路的 kind:音效与背景音乐。 -/// -/// 这份判据是「同一命令两种通道」的唯一分叉点:它在白名单里只放这两个成员,其余 kind -/// (含图片类与 `unknown`)一律继续走图片通道的既有收口,不在这一层做兜底猜测。 -fn is_audio_asset_generation_kind(kind: GameCreationAppAssetKind) -> bool { - matches!( - kind, - GameCreationAppAssetKind::SoundEffect | GameCreationAppAssetKind::BackgroundMusic - ) -} - /// 音频(音效 / 背景音乐)提交:校验入参 → 落**同一份**排队记录 → 返回记录与派发所需的请求。 /// /// 与图片类分支的差异只有三处,且都不改变账本形状: @@ -485,167 +432,6 @@ fn begin_local_project_audio_generation_task( Ok((record, request)) } -/// 音频后台执行:状态与阶段文案的每一次流转都由这里写账本。 -/// -/// `run_local_project_audio_generation_at` 返回 `Ok(None)` 表示这次生成没有登记出素材:按失败 -/// 收口,不把一条没有 `assetId` 的记录标成「已完成」——那样前端既定位不到素材,也没有原因可看。 -async fn run_local_project_audio_generation_task( - project_path: String, - task_id: String, - request: LocalProjectAudioGenerationRequest, -) { - let root = PathBuf::from(project_path.trim()); - if update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); - task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string(); - task.started_at_millis = Some(now_millis()); - }) - .is_err() - { - remove_live_task_id(&task_id); - return; - } - let outcome = run_local_project_audio_generation_at(&project_path, &request).await; - match outcome { - Ok(Some(asset_id)) => { - let _ = update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); - task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); - task.asset_id = Some(asset_id); - task.finished_at_millis = Some(now_millis()); - task.error = None; - }); - } - Ok(None) => { - let _ = update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); - task.phase_detail = - format!("生成失败:{ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR}"); - task.error = Some(ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR.to_string()); - task.finished_at_millis = Some(now_millis()); - }); - } - Err(error) => { - let _ = update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); - task.phase_detail = format!("生成失败:{error}"); - task.error = Some(error.clone()); - task.finished_at_millis = Some(now_millis()); - }); - } - } - remove_live_task_id(&task_id); -} - -/// 提交即返回:校验入参 → 落排队记录 → 派发后台任务 → 返回记录。 -/// -/// 入参收口完全复用 `prepare_local_project_asset_generation`(与同步命令同一份白名单与边界), -/// 生成本身仍是 `generate_platform_art_asset_with_options_at`,本命令不复制任何生成逻辑。 -/// -/// 音频 kind(`sound-effect` / `background-music`)走同一条命令的音频分支:账本、阶段文案、 -/// 中断收口与本地排队全部共用,**只**把「怎么生成」换成既有音频无源生成链路(见 -/// `start_local_project_audio_generation_task`)。图片类载荷口径逐字不变。 -#[tauri::command] -pub(crate) async fn start_local_project_asset_generation( - project_path: String, - project_id: String, - task_id: String, - kind: String, - prompt: String, - aspect_ratio: Option, - image_size: Option, - asset_name: Option, - output_path: Option, - // 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入, - // 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。 - reference_asset_ids: Option>, - // 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本: - // 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。 - target_category: Option, - // 前端 IPC 字段 `idempotencyKey`:**音频**生成才带——音频请求身份是一对 operation / 幂等键, - // 重试必须复用同一对,否则就变成第二次付费生成。图片类通道的载荷逐字不变,这个字段对 - // 图片 kind 不参与任何校验。 - idempotency_key: Option, -) -> Result { - let task_id = asset_generation_task_id(&task_id)?; - if is_audio_asset_generation_kind(GameCreationAppAssetKind::parse_with_context( - &kind, - "canvas.asset_kind", - )) { - let idempotency_key = idempotency_key.unwrap_or_default(); - if idempotency_key.trim().is_empty() { - return Err("音频生成缺少 idempotencyKey".to_string()); - } - // 先登记 live 再落账本:窗口期里并发 `list` 不许把这条排队记录判成上次运行的残留。 - let live_registered = register_live_task_id(&task_id); - let (record, request) = match begin_local_project_audio_generation_task( - &project_path, - &project_id, - &task_id, - &kind, - &prompt, - asset_name.as_deref().unwrap_or_default(), - &idempotency_key, - ) { - Ok(pair) => pair, - Err(error) => { - // 校验不过 / 同 id 已在跑 / 账本写不进去:这一轮什么都没派发,撤掉自己的登记。 - if live_registered { - remove_live_task_id(&task_id); - } - return Err(error); - } - }; - tauri::async_runtime::spawn(run_local_project_audio_generation_task( - project_path.trim().to_string(), - task_id, - request, - )); - return Ok(record); - } - let request = prepare_local_project_asset_generation( - &project_path, - &kind, - &prompt, - aspect_ratio.as_deref(), - image_size.as_deref(), - asset_name.as_deref(), - output_path.as_deref(), - reference_asset_ids.as_deref().unwrap_or_default(), - target_category.as_deref(), - )?; - enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; - enforce_project_permission_policy(&request.root, "asset.register")?; - let asset_label = request.options.asset_label.clone(); - let asset_kind = request.options.asset_kind.clone(); - // 与音频分支同一条顺序约束:先登记 live 再落账本,中间不留「排队但还不 live」的窗口。 - let live_registered = register_live_task_id(&task_id); - let record = match begin_local_project_asset_generation_task( - &request.root, - &project_id, - &task_id, - asset_kind, - &asset_label, - request.options.output_path.as_deref(), - ) { - Ok(record) => record, - Err(error) => { - if live_registered { - remove_live_task_id(&task_id); - } - return Err(error); - } - }; - let root = request.root.clone(); - tauri::async_runtime::spawn(run_local_project_asset_generation_task( - root, - task_id, - request.prompt, - request.options, - )); - Ok(record) -} - /// 读回项目内账本(重开项目后仍能看到历史任务)。 #[tauri::command] pub(crate) fn list_local_project_asset_generations( @@ -1113,6 +899,11 @@ mod asset_generation_task_tests { assert_eq!(record.phase_detail, ASSET_GENERATION_TASK_PHASE_QUEUED); assert!(record.output_path.is_none()); assert_eq!(record.asset_name, "新背景音乐"); + assert_eq!(request.operation_id, record.task_id); + assert_eq!( + request.idempotency_key, + "9a1b2c3d-4e5f-4a1b-8c2d-3e4f5a6b7c8d" + ); assert_eq!(request.prompt, "一段平静的钢琴曲"); assert_eq!( request.edit_kind, diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks/runtime.rs new file mode 100644 index 000000000..4a3ccad0d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks/runtime.rs @@ -0,0 +1,223 @@ +use super::*; +use crate::agent::{ + generate_platform_art_asset_with_options_at, PlatformArtAssetGenerationOptions, +}; +use crate::commands::prepare_local_project_asset_generation; +use crate::project::run_local_project_audio_generation_at; + +pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running"; +const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。"; +/// 音频任务收口:通道跑完但没有登记出素材(`derive` 在有源 / 无源两条路上都必须登记 assets)。 +const ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR: &str = "生成完成但未登记素材"; + +/// 后台执行:状态与阶段文案的每一次流转都由这里写账本。 +async fn run_local_project_asset_generation_task( + root: PathBuf, + task_id: String, + prompt: String, + options: PlatformArtAssetGenerationOptions, +) { + if update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string(); + task.started_at_millis = Some(now_millis()); + }) + .is_err() + { + remove_live_task_id(&task_id); + return; + } + let outcome = generate_platform_art_asset_with_options_at(&root, &prompt, &[], &options).await; + match outcome { + Ok(generated) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); + task.asset_id = Some(generated.asset.id.clone()); + task.finished_at_millis = Some(now_millis()); + task.error = None; + }); + } + Err(error) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = format!("生成失败:{error}"); + task.error = Some(error.clone()); + task.finished_at_millis = Some(now_millis()); + }); + } + } + remove_live_task_id(&task_id); +} + +/// 走音频无源生成链路的 kind:音效与背景音乐。 +/// +/// 这份判据是「同一命令两种通道」的唯一分叉点:它在白名单里只放这两个成员,其余 kind +/// (含图片类与 `unknown`)一律继续走图片通道的既有收口,不在这一层做兜底猜测。 +fn is_audio_asset_generation_kind(kind: GameCreationAppAssetKind) -> bool { + matches!( + kind, + GameCreationAppAssetKind::SoundEffect | GameCreationAppAssetKind::BackgroundMusic + ) +} + +/// 音频后台执行:状态与阶段文案的每一次流转都由这里写账本。 +/// +/// `run_local_project_audio_generation_at` 返回 `Ok(None)` 表示这次生成没有登记出素材:按失败 +/// 收口,不把一条没有 `assetId` 的记录标成「已完成」——那样前端既定位不到素材,也没有原因可看。 +async fn run_local_project_audio_generation_task( + project_path: String, + task_id: String, + request: LocalProjectAudioGenerationRequest, +) { + let root = PathBuf::from(project_path.trim()); + if update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string(); + task.started_at_millis = Some(now_millis()); + }) + .is_err() + { + remove_live_task_id(&task_id); + return; + } + let outcome = run_local_project_audio_generation_at(&project_path, &request).await; + match outcome { + Ok(Some(asset_id)) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); + task.asset_id = Some(asset_id); + task.finished_at_millis = Some(now_millis()); + task.error = None; + }); + } + Ok(None) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = + format!("生成失败:{ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR}"); + task.error = Some(ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR.to_string()); + task.finished_at_millis = Some(now_millis()); + }); + } + Err(error) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = format!("生成失败:{error}"); + task.error = Some(error.clone()); + task.finished_at_millis = Some(now_millis()); + }); + } + } + remove_live_task_id(&task_id); +} + +/// 提交即返回:校验入参 → 落排队记录 → 派发后台任务 → 返回记录。 +/// +/// 入参收口完全复用 `prepare_local_project_asset_generation`(与同步命令同一份白名单与边界), +/// 生成本身仍是 `generate_platform_art_asset_with_options_at`,本命令不复制任何生成逻辑。 +/// +/// 音频 kind(`sound-effect` / `background-music`)走同一条命令的音频分支:账本、阶段文案、 +/// 中断收口与本地排队全部共用,**只**把「怎么生成」换成既有音频无源生成链路(见 +/// `start_local_project_audio_generation_task`)。图片类载荷口径逐字不变。 +#[tauri::command] +pub(crate) async fn start_local_project_asset_generation( + project_path: String, + project_id: String, + task_id: String, + kind: String, + prompt: String, + aspect_ratio: Option, + image_size: Option, + asset_name: Option, + output_path: Option, + // 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入, + // 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。 + reference_asset_ids: Option>, + // 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本: + // 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。 + target_category: Option, + // 前端 IPC 字段 `idempotencyKey`:**音频**生成才带——音频请求身份是一对 operation / 幂等键, + // 重试必须复用同一对,否则就变成第二次付费生成。图片类通道的载荷逐字不变,这个字段对 + // 图片 kind 不参与任何校验。 + idempotency_key: Option, +) -> Result { + let task_id = asset_generation_task_id(&task_id)?; + if is_audio_asset_generation_kind(GameCreationAppAssetKind::parse_with_context( + &kind, + "canvas.asset_kind", + )) { + let idempotency_key = idempotency_key.unwrap_or_default(); + if idempotency_key.trim().is_empty() { + return Err("音频生成缺少 idempotencyKey".to_string()); + } + // 先登记 live 再落账本:窗口期里并发 `list` 不许把这条排队记录判成上次运行的残留。 + let live_registered = register_live_task_id(&task_id); + let (record, request) = match begin_local_project_audio_generation_task( + &project_path, + &project_id, + &task_id, + &kind, + &prompt, + asset_name.as_deref().unwrap_or_default(), + &idempotency_key, + ) { + Ok(pair) => pair, + Err(error) => { + // 校验不过 / 同 id 已在跑 / 账本写不进去:这一轮什么都没派发,撤掉自己的登记。 + if live_registered { + remove_live_task_id(&task_id); + } + return Err(error); + } + }; + tauri::async_runtime::spawn(run_local_project_audio_generation_task( + project_path.trim().to_string(), + task_id, + request, + )); + return Ok(record); + } + let request = prepare_local_project_asset_generation( + &project_path, + &kind, + &prompt, + aspect_ratio.as_deref(), + image_size.as_deref(), + asset_name.as_deref(), + output_path.as_deref(), + reference_asset_ids.as_deref().unwrap_or_default(), + target_category.as_deref(), + )?; + enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; + enforce_project_permission_policy(&request.root, "asset.register")?; + let asset_label = request.options.asset_label.clone(); + let asset_kind = request.options.asset_kind.clone(); + // 与音频分支同一条顺序约束:先登记 live 再落账本,中间不留「排队但还不 live」的窗口。 + let live_registered = register_live_task_id(&task_id); + let record = match begin_local_project_asset_generation_task( + &request.root, + &project_id, + &task_id, + asset_kind, + &asset_label, + request.options.output_path.as_deref(), + ) { + Ok(record) => record, + Err(error) => { + if live_registered { + remove_live_task_id(&task_id); + } + return Err(error); + } + }; + let root = request.root.clone(); + tauri::async_runtime::spawn(run_local_project_asset_generation_task( + root, + task_id, + request.prompt, + request.options, + )); + Ok(record) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 42280400d..01e556e37 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -2032,6 +2032,7 @@ fn register_local_asset_entry_with_change( )) } +#[cfg(not(test))] #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct ReadLocalProjectAssetReferencesInput { @@ -2099,6 +2100,7 @@ pub(crate) fn read_manifest_asset_references_at( }) } +#[cfg(not(test))] #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct DeleteLocalProjectAssetInput { diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs index 41bf2cf64..0d98e7c97 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -32,6 +32,7 @@ use super::playtest::{ GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES, GENERIC_PLAYTEST_START_OPPORTUNITY_WINDOW, PLAYABLE_GAME_STATE_SCHEMA_VERSION, }; +#[cfg(unix)] use super::process::create_browser_process_temp_dir; static PATH_TEST_LOCK: Mutex<()> = Mutex::new(()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs b/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs index 6047adc23..323ecc870 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs @@ -4,10 +4,13 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; -use tauri::Manager; -use tauri_plugin_dialog::DialogExt; use zip::ZipArchive; +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::*; + const CLIENT_EXTENSIONS_DIR_NAME: &str = "extensions"; const CLIENT_EXTENSIONS_INDEX_FILE_NAME: &str = "index.json"; const CLIENT_EXTENSIONS_SCHEMA_VERSION: &str = "direct-project-client-extensions.v1"; @@ -1284,84 +1287,6 @@ fn record_client_mcp_startup_status_at( }) } -#[tauri::command] -pub(crate) async fn pick_client_extension_file( - app: tauri::AppHandle, -) -> Result, String> { - let (sender, receiver) = tokio::sync::oneshot::channel(); - let mut dialog = app - .dialog() - .file() - .set_title("导入 Skill、MCP 或 Plugin 文件"); - if let Some(window) = app.get_webview_window("client") { - dialog = dialog.set_parent(&window); - } - dialog.pick_file(move |path| { - let _ = sender.send(path); - }); - let Some(path) = receiver - .await - .map_err(|_| "扩展文件选择器意外关闭".to_string())? - else { - return Ok(None); - }; - let path = path - .into_path() - .map_err(|error| format!("读取扩展文件失败:{error}"))?; - #[cfg(windows)] - crate::register_game_creator_user_selected_path(&path, false); - if let Err(error) = - crate::prepare_game_creator_user_selected_path_for_read(&path, false, "用户选择扩展文件") - { - #[cfg(windows)] - crate::revoke_game_creator_user_selected_path(&path); - return Err(error); - } - Ok(Some(path.to_string_lossy().into_owned())) -} - -#[tauri::command] -pub(crate) async fn pick_client_extension_directory( - app: tauri::AppHandle, -) -> Result, String> { - let (sender, receiver) = tokio::sync::oneshot::channel(); - let mut dialog = app - .dialog() - .file() - .set_title("导入 Skill、MCP 或 Plugin 目录"); - if let Some(window) = app.get_webview_window("client") { - dialog = dialog.set_parent(&window); - } - dialog.pick_folder(move |path| { - let _ = sender.send(path); - }); - let Some(path) = receiver - .await - .map_err(|_| "扩展目录选择器意外关闭".to_string())? - else { - return Ok(None); - }; - let path = path - .into_path() - .map_err(|error| format!("读取扩展目录失败:{error}"))?; - #[cfg(windows)] - crate::register_game_creator_user_selected_path(&path, true); - if let Err(error) = - crate::prepare_game_creator_user_selected_path_for_read(&path, true, "用户选择扩展目录") - { - #[cfg(windows)] - crate::revoke_game_creator_user_selected_path(&path); - return Err(error); - } - Ok(Some(path.to_string_lossy().into_owned())) -} - -#[tauri::command] -pub(crate) fn list_client_extensions() -> Result, String> { - let root = extensions_root()?; - list_client_extensions_at(&root) -} - pub(crate) fn list_client_extensions_at(root: &Path) -> Result, String> { read_client_extension_index_locked(&root, |_, index| { Ok(index @@ -1431,35 +1356,6 @@ fn extension_effectively_enabled(index: &ClientExtensionIndex, item: &StoredExte }) } -#[tauri::command] -pub(crate) fn import_client_extension( - source_path: String, - host: tauri::State<'_, crate::plugin_host::PluginHost>, -) -> Result { - let source = PathBuf::from(source_path.trim()); - if source.as_os_str().is_empty() { - return Err("扩展来源不能为空".to_string()); - } - if !source.is_absolute() { - return Err("扩展来源必须是绝对路径".to_string()); - } - let metadata = fs::metadata(&source) - .map_err(|error| format!("读取扩展来源失败:{}: {error}", source.display()))?; - if !metadata.is_file() && !metadata.is_dir() { - return Err("扩展来源必须是普通文件或目录".to_string()); - } - crate::prepare_game_creator_user_selected_path_for_read( - &source, - metadata.is_dir(), - "客户端扩展来源", - )?; - - let root = extensions_root()?; - let result = import_client_extension_at(&root, &source, &metadata)?; - host.refresh()?; - Ok(result) -} - pub(crate) fn import_client_extension_at( root: &Path, source: &Path, @@ -1581,18 +1477,6 @@ pub(crate) fn import_client_extension_at( result } -#[tauri::command] -pub(crate) fn set_client_extension_enabled( - id: String, - enabled: bool, - host: tauri::State<'_, crate::plugin_host::PluginHost>, -) -> Result { - let root = extensions_root()?; - let item = set_client_extension_enabled_at(&root, &id, enabled)?; - host.refresh()?; - Ok(item) -} - fn set_client_extension_enabled_at( root: &Path, id: &str, @@ -1627,23 +1511,6 @@ fn set_client_extension_enabled_at( }) } -#[tauri::command] -pub(crate) fn rename_client_extension( - id: String, - name: String, - host: tauri::State<'_, crate::plugin_host::PluginHost>, -) -> Result { - let requested = name.trim(); - if requested.is_empty() { - return Err("扩展名称不能为空".to_string()); - } - let normalized = native_name(requested); - let root = extensions_root()?; - let item = rename_client_extension_at(&root, &id, &normalized)?; - host.refresh()?; - Ok(item) -} - fn rename_client_extension_at( root: &Path, id: &str, @@ -1691,17 +1558,6 @@ fn rename_client_extension_at( }) } -#[tauri::command] -pub(crate) fn remove_client_extension( - id: String, - host: tauri::State<'_, crate::plugin_host::PluginHost>, -) -> Result<(), String> { - let root = extensions_root()?; - remove_client_extension_at(&root, &id)?; - host.refresh()?; - Ok(()) -} - fn remove_client_extension_at(root: &Path, id: &str) -> Result<(), String> { invalidate_client_mcp_connection(id.trim())?; update_client_extension_index_at(root, |_, index| { diff --git a/apps/ai-game-creator-shell/src-tauri/src/client_extensions/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/client_extensions/desktop.rs new file mode 100644 index 000000000..e51d73f60 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/client_extensions/desktop.rs @@ -0,0 +1,150 @@ +use super::*; +use tauri::Manager; +use tauri_plugin_dialog::DialogExt; + +#[tauri::command] +pub(crate) async fn pick_client_extension_file( + app: tauri::AppHandle, +) -> Result, String> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let mut dialog = app + .dialog() + .file() + .set_title("导入 Skill、MCP 或 Plugin 文件"); + if let Some(window) = app.get_webview_window("client") { + dialog = dialog.set_parent(&window); + } + dialog.pick_file(move |path| { + let _ = sender.send(path); + }); + let Some(path) = receiver + .await + .map_err(|_| "扩展文件选择器意外关闭".to_string())? + else { + return Ok(None); + }; + let path = path + .into_path() + .map_err(|error| format!("读取扩展文件失败:{error}"))?; + #[cfg(windows)] + crate::register_game_creator_user_selected_path(&path, false); + if let Err(error) = + crate::prepare_game_creator_user_selected_path_for_read(&path, false, "用户选择扩展文件") + { + #[cfg(windows)] + crate::revoke_game_creator_user_selected_path(&path); + return Err(error); + } + Ok(Some(path.to_string_lossy().into_owned())) +} + +#[tauri::command] +pub(crate) async fn pick_client_extension_directory( + app: tauri::AppHandle, +) -> Result, String> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let mut dialog = app + .dialog() + .file() + .set_title("导入 Skill、MCP 或 Plugin 目录"); + if let Some(window) = app.get_webview_window("client") { + dialog = dialog.set_parent(&window); + } + dialog.pick_folder(move |path| { + let _ = sender.send(path); + }); + let Some(path) = receiver + .await + .map_err(|_| "扩展目录选择器意外关闭".to_string())? + else { + return Ok(None); + }; + let path = path + .into_path() + .map_err(|error| format!("读取扩展目录失败:{error}"))?; + #[cfg(windows)] + crate::register_game_creator_user_selected_path(&path, true); + if let Err(error) = + crate::prepare_game_creator_user_selected_path_for_read(&path, true, "用户选择扩展目录") + { + #[cfg(windows)] + crate::revoke_game_creator_user_selected_path(&path); + return Err(error); + } + Ok(Some(path.to_string_lossy().into_owned())) +} + +#[tauri::command] +pub(crate) fn list_client_extensions() -> Result, String> { + let root = extensions_root()?; + list_client_extensions_at(&root) +} + +#[tauri::command] +pub(crate) fn import_client_extension( + source_path: String, + host: tauri::State<'_, crate::plugin_host::PluginHost>, +) -> Result { + let source = PathBuf::from(source_path.trim()); + if source.as_os_str().is_empty() { + return Err("扩展来源不能为空".to_string()); + } + if !source.is_absolute() { + return Err("扩展来源必须是绝对路径".to_string()); + } + let metadata = fs::metadata(&source) + .map_err(|error| format!("读取扩展来源失败:{}: {error}", source.display()))?; + if !metadata.is_file() && !metadata.is_dir() { + return Err("扩展来源必须是普通文件或目录".to_string()); + } + crate::prepare_game_creator_user_selected_path_for_read( + &source, + metadata.is_dir(), + "客户端扩展来源", + )?; + + let root = extensions_root()?; + let result = import_client_extension_at(&root, &source, &metadata)?; + host.refresh()?; + Ok(result) +} + +#[tauri::command] +pub(crate) fn set_client_extension_enabled( + id: String, + enabled: bool, + host: tauri::State<'_, crate::plugin_host::PluginHost>, +) -> Result { + let root = extensions_root()?; + let item = set_client_extension_enabled_at(&root, &id, enabled)?; + host.refresh()?; + Ok(item) +} + +#[tauri::command] +pub(crate) fn rename_client_extension( + id: String, + name: String, + host: tauri::State<'_, crate::plugin_host::PluginHost>, +) -> Result { + let requested = name.trim(); + if requested.is_empty() { + return Err("扩展名称不能为空".to_string()); + } + let normalized = native_name(requested); + let root = extensions_root()?; + let item = rename_client_extension_at(&root, &id, &normalized)?; + host.refresh()?; + Ok(item) +} + +#[tauri::command] +pub(crate) fn remove_client_extension( + id: String, + host: tauri::State<'_, crate::plugin_host::PluginHost>, +) -> Result<(), String> { + let root = extensions_root()?; + remove_client_extension_at(&root, &id)?; + host.refresh()?; + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 072750eca..d77fc38b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,8 +1,9 @@ +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::*; + use super::*; -use crate::agent::{ - read_direct_project_chat_history_at, read_direct_project_last_item_id_at, - DirectProjectHistoryAnchor, -}; use crate::ui_editor::resource::font::FontAsset; use base64::Engine as _; use sha2::{Digest, Sha256}; @@ -20,21 +21,8 @@ const AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS: usize = 500; const AGENT_EDITOR_ASSET_ID_MAX_CHARS: usize = 512; const EDITOR_ASSET_PREVIEW_MAX_BYTES: usize = 8 * 1024 * 1024; const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000; -const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64; -// Project naming is an optional homepage enhancement. It must never hold the -// actual project creation flow behind the normal (potentially three-minute) -// generation timeout. -const AUTOMATIC_PROJECT_NAME_TIMEOUT_MS: u64 = 15_000; -const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str = - include_str!("../prompts/automatic-project-name.md"); -// 聊天输入区的 AI 润色复用同一条短文本生成通道:只做一次单轮改写, -// 计费由平台 LLM 路由(/api/llm/chat/completions、/api/llm/responses)侧完成。 const LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS: usize = 4_000; const LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS: usize = 1_000; -const LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS: u32 = 2_048; -const LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT: &str = - include_str!("../prompts/local-project-prompt-polish.md"); - fn is_chinese_project_name_character(value: char) -> bool { matches!( value, @@ -67,51 +55,6 @@ pub(crate) fn build_automatic_project_name_prompt(prompt: &str) -> Result Result, String> { - let user_prompt = build_automatic_project_name_prompt(prompt)?; - let app_config = load_game_creator_app_config()?; - if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { - let reply = tokio::time::timeout( - std::time::Duration::from_millis(AUTOMATIC_PROJECT_NAME_TIMEOUT_MS), - crate::agent::direct_game_creator_home_codex_chat( - AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim().to_string(), - user_prompt, - ), - ) - .await - .map_err(|_| { - format!( - "自动项目命名超时(超过 {} 秒)", - AUTOMATIC_PROJECT_NAME_TIMEOUT_MS / 1_000 - ) - })??; - return Ok(normalize_suggested_project_name(reply.trim())); - } - let mut llm = app_config.llm.clone(); - llm.max_retries = 0; - let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?; - let request = - LlmRunRequest::single_turn(AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim(), user_prompt) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_model(llm.model.clone()) - .with_request_timeout_ms(llm.request_timeout_ms) - .with_max_output_tokens(AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS) - .with_web_search(false); - let response = tokio::time::timeout( - std::time::Duration::from_millis(AUTOMATIC_PROJECT_NAME_TIMEOUT_MS), - client.run(request), - ) - .await - .map_err(|_| { - format!( - "自动项目命名超时(超过 {} 秒)", - AUTOMATIC_PROJECT_NAME_TIMEOUT_MS / 1_000 - ) - })? - .map_err(|error| format!("自动项目命名失败:{error}"))?; - Ok(normalize_suggested_project_name(response.text.trim())) -} - pub(crate) fn build_local_project_prompt_polish_prompt( prompt: &str, context: Option<&str>, @@ -145,40 +88,6 @@ fn normalize_polished_prompt(value: &str) -> Option { } } -async fn request_local_project_prompt_polish( - prompt: &str, - context: Option<&str>, -) -> Result { - let user_prompt = build_local_project_prompt_polish_prompt(prompt, context)?; - let app_config = load_game_creator_app_config()?; - if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { - let reply = crate::agent::direct_game_creator_home_codex_chat( - LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim().to_string(), - user_prompt, - ) - .await?; - return normalize_polished_prompt(&reply) - .ok_or_else(|| "AI 润色没有返回可用文本".to_string()); - } - let mut llm = app_config.llm.clone(); - llm.max_retries = 0; - let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?; - let request = LlmRunRequest::single_turn( - LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim(), - user_prompt, - ) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_model(llm.model.clone()) - .with_request_timeout_ms(llm.request_timeout_ms) - .with_max_output_tokens(LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS) - .with_web_search(false); - let response = client - .run(request) - .await - .map_err(|error| format!("AI 润色失败:{error}"))?; - normalize_polished_prompt(&response.text).ok_or_else(|| "AI 润色没有返回可用文本".to_string()) -} - #[cfg(test)] mod local_project_prompt_polish_tests { use super::*; @@ -469,21 +378,6 @@ pub(crate) fn closest_existing_project_picker_directory(path: &Path) -> Option

Result { - app.path() - .app_data_dir() - .map(|app_data_root| { - // Automatic workspaces are AGC-managed data. Keeping them below - // the hardened per-user app-data root avoids applying the strict - // private-DACL gate to a user Documents directory whose inherited - // ACL AGC is not allowed to rewrite. - app_data_root.join(AUTOMATIC_PROJECTS_DIRECTORY_NAME) - }) - .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) -} - /// 校验用户选择的项目创建目录。 /// /// 目录必须已经存在(原生目录选择器返回的结果),并且先过 AGC 私有路径门禁:门禁失败时 @@ -510,22 +404,6 @@ pub(crate) fn validate_requested_game_project_creation_root( Ok(root.to_path_buf()) } -/// 解析本次建项要使用的根目录:显式参数优先,其次读取 Rust 工作区偏好,最后回落 AGC 默认目录。 -pub(crate) fn resolve_game_project_creation_root( - app: &tauri::AppHandle, - requested: Option<&str>, -) -> Result { - let stored = requested - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .or_else(|| crate::workspace_preferences::stored_project_creation_directory(app)); - match stored.as_deref() { - Some(requested) => validate_requested_game_project_creation_root(requested), - None => automatic_local_game_projects_root(app), - } -} - pub(crate) fn create_automatic_local_game_project_at( projects_root: &Path, requested_name: Option<&str>, @@ -590,61 +468,6 @@ pub(crate) fn create_automatic_local_game_project_at( Err("自动工作区命名冲突,请重试".to_string()) } -#[tauri::command] -pub(crate) fn create_automatic_local_game_project( - app: tauri::AppHandle, - name: Option, - planning: Option, - projects_root: Option, -) -> Result { - let analytics_context = crate::analytics::gui::capture_analytics_context(); - let result = create_automatic_local_game_project_at( - &resolve_game_project_creation_root(&app, projects_root.as_deref())?, - name.as_deref(), - planning.unwrap_or(false), - ); - if let Ok(project) = &result { - crate::analytics::gui::created( - analytics_context, - Path::new(&project.project_path), - project.manifest.project_id.clone(), - if planning.unwrap_or(false) { - crate::analytics::contract::CreationSource::HomeDesign - } else { - crate::analytics::contract::CreationSource::HomeGame - }, - None, - ); - } - result -} - -#[tauri::command] -pub(crate) fn init_local_game_project( - project_path: String, - project_id: String, - name: String, -) -> Result { - let analytics_context = crate::analytics::gui::capture_analytics_context(); - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.create")?; - let _lock = acquire_project_write_lock(root, "project.create")?; - let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); - let result = init_local_game_project_at(root, project_id.trim(), name.trim()); - if new_project { - if let Ok(project) = &result { - crate::analytics::gui::created( - analytics_context, - Path::new(&project.project_path), - project.manifest.project_id.clone(), - crate::analytics::contract::CreationSource::SelectedDirectory, - None, - ); - } - } - result -} - #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct RenameLocalProjectResult { @@ -708,67 +531,6 @@ pub(crate) fn import_local_godot_project( result } -#[tauri::command] -pub(crate) fn import_local_cocos_project( - project_path: String, - project_id: String, - name: String, -) -> Result { - let analytics_context = crate::analytics::gui::capture_analytics_context(); - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.create")?; - if discover_local_cocos_project_root(root)?.is_none() { - return Err( - "所选目录不是有效的 Cocos Creator 项目(需要 package.json.creator.version 和 assets/)" - .to_string(), - ); - } - let _lock = acquire_project_write_lock(root, "project.create")?; - let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); - let result = import_local_cocos_project_at(root, project_id.trim(), name.trim()); - if new_project { - if let Ok(project) = &result { - crate::analytics::gui::created( - analytics_context, - Path::new(&project.project_path), - project.manifest.project_id.clone(), - crate::analytics::contract::CreationSource::SelectedDirectory, - None, - ); - } - } - result -} - -#[tauri::command] -pub(crate) fn import_local_unity_project( - project_path: String, - project_id: String, - name: String, -) -> Result { - let analytics_context = crate::analytics::gui::capture_analytics_context(); - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.create")?; - if discover_local_unity_project_root(root)?.is_none() { - return Err("所选目录不是有效的 Unity 项目".to_string()); - } - let _lock = acquire_project_write_lock(root, "project.create")?; - let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); - let result = import_local_unity_project_at(root, project_id.trim(), name.trim()); - if new_project { - if let Ok(project) = &result { - crate::analytics::gui::created( - analytics_context, - Path::new(&project.project_path), - project.manifest.project_id.clone(), - crate::analytics::contract::CreationSource::SelectedDirectory, - None, - ); - } - } - result -} - #[tauri::command] pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Result { let root = Path::new(project_path.trim()); @@ -796,15 +558,6 @@ pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Resu .map(|entry| entry.is_some()) } -#[tauri::command] -pub(crate) async fn inspect_local_project_directory( - project_path: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || inspect_local_project_directory_sync(project_path)) - .await - .map_err(|error| format!("检查项目目录后台任务失败:{error}"))? -} - pub(crate) fn inspect_local_project_directory_sync( project_path: String, ) -> Result { @@ -898,84 +651,6 @@ pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option(&content).ok() } -/// 目录选择器标题:调用方只能给短标题,其余(超长、含控制字符、空白)一律回退默认文案。 -fn pick_project_directory_title(title: Option<&str>) -> &str { - const MAX_TITLE_CHARS: usize = 24; - title - .map(str::trim) - .filter(|value| { - !value.is_empty() - && value.chars().count() <= MAX_TITLE_CHARS - && !value.chars().any(char::is_control) - }) - .unwrap_or("选择游戏项目目录") -} - -#[tauri::command] -pub(crate) async fn pick_local_project_directory( - app: tauri::AppHandle, - initial_path: Option, - title: Option, -) -> Result, String> { - let (sender, receiver) = tokio::sync::oneshot::channel(); - let mut dialog = app - .dialog() - .file() - .set_title(pick_project_directory_title(title.as_deref())); - if let Some(initial_path) = initial_path - .as_deref() - .map(str::trim) - .filter(|path| !path.is_empty()) - { - let initial_path = Path::new(initial_path); - if initial_path.is_absolute() && !project_path_has_control_chars(initial_path) { - let starting_directory = closest_existing_project_picker_directory(initial_path); - if let Some(starting_directory) = starting_directory { - dialog = dialog.set_directory(starting_directory); - } - } - } - if let Some(window) = app.get_webview_window("client") { - dialog = dialog.set_parent(&window); - } - dialog.pick_folder(move |path| { - let _ = sender.send(path); - }); - let Some(path) = receiver - .await - .map_err(|_| "项目目录选择器意外关闭".to_string())? - else { - return Ok(None); - }; - let path = path - .into_path() - .map_err(|error| format!("读取项目目录失败:{error}"))?; - #[cfg(windows)] - crate::register_game_creator_user_selected_path(&path, true); - // The native picker is the explicit user-selection boundary. Prepare the - // selected root before returning it so inspect/create/open never races the - // first ACL read. - if let Err(error) = - crate::prepare_game_creator_project_root_for_read(&path, true, "用户选择项目目录") - { - #[cfg(windows)] - crate::revoke_game_creator_user_selected_path(&path); - return Err(error); - } - Ok(Some(path.to_string_lossy().into_owned())) -} - -#[tauri::command] -pub(crate) fn open_local_project_directory( - app: tauri::AppHandle, - project_path: String, -) -> Result<(), String> { - let path = validated_local_project_directory_path(project_path.trim())?; - app.opener() - .open_path(path.to_string_lossy().into_owned(), None::<&str>) - .map_err(|error| format!("打开项目目录失败:{error}")) -} - pub(crate) fn validated_local_project_directory_path( project_path: &str, ) -> Result { @@ -999,18 +674,6 @@ pub(crate) fn validated_local_project_directory_path( Ok(path.to_path_buf()) } -#[tauri::command] -pub(crate) async fn get_local_game_manifest( - project_path: String, - command_id: Option, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - get_local_game_manifest_sync(project_path, command_id) - }) - .await - .map_err(|error| format!("读取项目 manifest 后台任务失败:{error}"))? -} - pub(crate) fn get_local_game_manifest_sync( project_path: String, command_id: Option, @@ -1059,17 +722,6 @@ pub(crate) fn read_local_project_resource_canvas_layout( read_project_resource_canvas_layout_at(root, mode) } -#[tauri::command] -pub(crate) fn read_local_project_resource_graph( - project_path: String, - expected_project_id: String, - resources: Vec, -) -> Result { - let root = validated_local_project_directory_path(project_path.trim())?; - enforce_project_auto_permission_policy(&root, "asset.list")?; - read_project_resource_graph_at(&root, expected_project_id.trim(), resources) -} - /// 保存资源画布的持久化布局。 /// /// 权限位取 `asset.register`,依据是同批「改动项目内资源相关持久化数据」的既有用法:本文件里 @@ -1098,154 +750,6 @@ pub(crate) fn update_local_project_resource_canvas_layout( ) } -/// 读取引用了某个素材的项目版本,供删除弹窗提示「被哪些版本使用」。 -#[tauri::command] -pub(crate) fn read_local_project_asset_references( - input: ReadLocalProjectAssetReferencesInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.list")?; - read_manifest_asset_references_at(root, &input.asset_id) -} - -/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。 -/// -/// 素材不可变:只摘掉 manifest 登记,磁盘文件保留。 -#[tauri::command] -pub(crate) fn delete_local_project_asset( - input: DeleteLocalProjectAssetInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - delete_manifest_asset_at( - root, - &input.expected_project_id, - input.expected_project_revision, - &input.asset_id, - input.delete_referenced_versions, - ) -} - -/// 读取某个版本引用的源素材可用的替换候选,并给出后端权威的三项兼容性结论。 -/// -/// 只读:不改 manifest、不推进 revision。候选渲染但禁用,不在前端重算判据。 -#[tauri::command] -pub(crate) fn read_local_project_version_resource_replacement_candidates( - input: ReadLocalProjectVersionReplacementCandidatesInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.list")?; - read_local_project_version_replacement_candidates_at( - root, - &input.source_version_id, - &input.source_resource_id, - ) -} - -/// 用另一个已登记素材替换某个版本引用的素材:改 manifest 绑定,并**追加下一迭代版本**。 -/// -/// 可运行版本不可变(既有版本记录一个字节都不改)、不动资源文件、不建文件副本; -/// 三项兼容性必须同时为 true,否则拒绝并说明哪一项不等。CAS 失败时 manifest 与 revision 都不变。 -#[tauri::command] -pub(crate) fn replace_local_project_version_resource( - input: ReplaceLocalProjectVersionResourceInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - replace_local_project_version_resource_at( - root, - &input.expected_project_id, - input.expected_project_revision, - &input.source_version_id, - &input.source_resource_id, - &input.replacement_resource_id, - ) -} - -/// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。 -/// -/// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败 -/// 时把文件改回原名,不留半成品。与删除 / 分类更新同口径:持项目写锁后按 `expectedProjectId` -/// 与当前 revision 做 CAS,失败时磁盘、manifest 与 revision 都不动。 -#[tauri::command] -pub(crate) fn rename_local_project_asset( - input: RenameLocalProjectAssetInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - rename_local_project_asset_at( - root, - &input.expected_project_id, - input.expected_project_revision, - &input.asset_id, - &input.new_file_name, - ) -} - -#[tauri::command] -pub(crate) async fn control_agent_run( - app: tauri::AppHandle, - project_path: String, - action: String, - detail: Option, -) -> Result { - let root = Path::new(project_path.trim()); - let command_id = match action.trim() { - "status" => "agent.run_status", - "kill" => "agent.kill", - "retry" => "agent.retry", - "resume" => "agent.resume", - _ => "agent.run_status", - }; - enforce_project_permission_policy(root, command_id)?; - if matches!(action.trim(), "retry" | "resume") { - enforce_project_permission_policy(root, "game.generate_draft")?; - } - let _lock = if command_id == "agent.run_status" { - None - } else { - Some(acquire_project_write_lock(root, command_id)?) - }; - control_agent_run_at( - root, - action.trim(), - detail - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()), - Some(&AgentProgressEmitter::new(&app, project_path.trim())), - ) - .await -} - -#[tauri::command] -pub(crate) async fn generate_local_game_draft( - app: tauri::AppHandle, - project_path: String, - prompt: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "game.generate_draft")?; - let _lock = acquire_project_write_lock(root, "game.generate_draft")?; - advance_agent_runtime_project_revision_locked(root)?; - generate_local_game_draft_at( - root, - prompt.trim(), - Some(&AgentProgressEmitter::new(&app, project_path.trim())), - ) - .await -} - -#[tauri::command] -pub(crate) async fn chat_with_game_creator_agent( - project_path: String, - prompt: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - chat_with_game_creator_agent_at(root, prompt.trim()).await -} - #[tauri::command] pub(crate) async fn chat_with_game_creator_role_agent( project_path: String, @@ -1275,206 +779,6 @@ pub(crate) async fn chat_with_game_creator_role_agent( } } -#[tauri::command] -pub(crate) async fn chat_with_game_creator_role_agent_stream( - app: tauri::AppHandle, - project_path: String, - agent_id: String, - session_id: Option, - prompt: String, - run_id: String, -) -> Result { - let project_path = project_path.trim().to_string(); - let agent_id = normalize_game_creator_runtime_agent_id(agent_id.trim())?; - let run_id = run_id.trim().to_string(); - let root = Path::new(project_path.as_str()); - let session_id = - resolve_agent_conversation_session_id_at(root, &agent_id, session_id.as_deref(), true)?; - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)? - .ok_or_else(|| format!("Agent 正在执行其他前台或后台任务:{agent_id}"))?; - let emit_app = app.clone(); - let event_project_path = project_path.clone(); - let event_agent_id = agent_id.clone(); - let event_run_id = run_id.clone(); - let command_result = async { - let mut runtime_state = start_game_creator_agent_runtime_turn_for_session_at( - root, - agent_id.as_str(), - Some(&session_id), - prompt.trim(), - &run_id, - )?; - runtime_state = advance_game_creator_agent_runtime_turn_at( - root, - runtime_state, - "llm", - "请求 Agent LLM", - "已读取项目上下文,正在让 Agent 独立推理。", - )?; - let mut streaming_runtime_state = runtime_state.clone(); - streaming_runtime_state.current_action = "正在接收 Agent 回复".to_string(); - let _ = app.emit( - "game-creator-role-agent-chat-stream", - GameCreatorRoleAgentChatStreamEvent { - project_path: event_project_path.clone(), - agent_id: event_agent_id.clone(), - run_id: event_run_id.clone(), - status: "started".to_string(), - delta_text: String::new(), - accumulated_text: String::new(), - finish_reason: None, - session_id: Some(runtime_state.session_id.clone()), - runtime_status: Some(runtime_state.status.clone()), - runtime_phase: Some(runtime_state.phase.clone()), - runtime_summary: Some(runtime_state.current_action.clone()), - runtime_state: Some(runtime_state.clone()), - }, - ); - let result = chat_with_game_creator_role_agent_stream_for_session_at( - root, - agent_id.as_str(), - Some(&session_id), - prompt.trim(), - |delta| { - let _ = emit_app.emit( - "game-creator-role-agent-chat-stream", - GameCreatorRoleAgentChatStreamEvent { - project_path: event_project_path.clone(), - agent_id: event_agent_id.clone(), - run_id: event_run_id.clone(), - status: "delta".to_string(), - delta_text: delta.delta_text.clone(), - accumulated_text: delta.accumulated_text.clone(), - finish_reason: delta.finish_reason.clone(), - session_id: Some(streaming_runtime_state.session_id.clone()), - runtime_status: Some("running".to_string()), - runtime_phase: Some("llm".to_string()), - runtime_summary: Some("正在接收 Agent 回复".to_string()), - runtime_state: Some(streaming_runtime_state.clone()), - }, - ); - }, - ) - .await; - match result { - Ok(reply) => { - let completed_runtime = finish_game_creator_agent_runtime_turn_at( - root, - runtime_state, - &reply.reply_text, - )?; - let _ = app.emit( - "game-creator-role-agent-chat-stream", - GameCreatorRoleAgentChatStreamEvent { - project_path: project_path.clone(), - agent_id: agent_id.clone(), - run_id: run_id.clone(), - status: "completed".to_string(), - delta_text: String::new(), - accumulated_text: reply.reply_text.clone(), - finish_reason: None, - session_id: Some(completed_runtime.session_id.clone()), - runtime_status: Some(completed_runtime.status.clone()), - runtime_phase: Some(completed_runtime.phase.clone()), - runtime_summary: Some(completed_runtime.current_action.clone()), - runtime_state: Some(completed_runtime), - }, - ); - Ok(reply) - } - Err(error) => { - let failed_runtime = - fail_game_creator_agent_runtime_turn_at(root, runtime_state, &error).ok(); - let _ = app.emit( - "game-creator-role-agent-chat-stream", - GameCreatorRoleAgentChatStreamEvent { - project_path: project_path.clone(), - agent_id: agent_id.clone(), - run_id: run_id.clone(), - status: "failed".to_string(), - delta_text: String::new(), - accumulated_text: String::new(), - finish_reason: None, - session_id: failed_runtime - .as_ref() - .map(|runtime| runtime.session_id.clone()), - runtime_status: failed_runtime - .as_ref() - .map(|runtime| runtime.status.clone()), - runtime_phase: failed_runtime.as_ref().map(|runtime| runtime.phase.clone()), - runtime_summary: failed_runtime - .as_ref() - .map(|runtime| runtime.current_action.clone()), - runtime_state: failed_runtime, - }, - ); - Err(error) - } - } - } - .await; - spawn_next_game_creator_agent_background_task_drain_with_lock(root, &agent_id, runtime_lock); - match command_result { - Ok(reply) => Ok(reply), - Err(error) => Err(error), - } -} - -#[tauri::command] -pub(crate) fn start_game_creator_supervisor_runtime_task( - project_path: String, - session_id: Option, - task: String, - run_id: String, - run_profile: Option, - source: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - enforce_project_permission_policy(root, "agent.run_status")?; - let run_profile = run_profile - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(AGENT_RUNTIME_RUN_PROFILE_STANDARD); - let source = source - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE); - if !agent_runtime_supervisor_source_is_trusted(source) { - return Err("Project Supervisor 提交 source 不受信任".to_string()); - } - start_game_creator_supervisor_background_task_for_session_at( - root, - session_id.as_deref(), - task.trim(), - run_id.trim(), - source, - run_profile, - ) -} - -#[tauri::command] -pub(crate) async fn compact_game_creator_agent_runtime_context( - project_path: String, - agent_id: String, - session_id: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "agent.compact")?; - if external_agent_runner_enabled() && !external_agent_runner_is_server_process() { - compact_external_agent_runner_context(root, agent_id.trim(), session_id.as_deref()) - } else { - compact_game_creator_agent_runtime_session_at(root, agent_id.trim(), session_id.as_deref()) - .await - } -} - #[tauri::command] pub(crate) fn read_game_creator_agent_goal( project_path: String, @@ -1603,19 +907,6 @@ pub(crate) fn clear_game_creator_agent_goal( ) } -#[tauri::command] -pub(crate) fn cancel_game_creator_agent_runtime_task( - project_path: String, - agent_id: String, - run_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - enforce_project_permission_policy(root, "agent.run_status")?; - cancel_game_creator_agent_runtime_task_at(root, agent_id.trim(), run_id.trim()) -} - #[tauri::command] pub(crate) fn retry_game_creator_agent_runtime_task( project_path: String, @@ -1703,52 +994,6 @@ pub(crate) fn reject_game_creator_agent_runtime_task( ) } -#[tauri::command] -pub(crate) fn answer_game_creator_agent_runtime_user_input( - project_path: String, - agent_id: String, - run_id: String, - action_id: String, - request_id: String, - response_id: String, - answers: BTreeMap, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - enforce_project_permission_policy(root, "agent.run_status")?; - enforce_project_permission_policy(root, "agent.resume")?; - answer_game_creator_agent_runtime_user_input_at( - root, - agent_id.trim(), - run_id.trim(), - action_id.trim(), - request_id.trim(), - response_id.trim(), - answers, - ) -} - -#[tauri::command] -pub(crate) fn read_game_creator_agent_runtime( - project_path: String, - agent_id: String, - session_id: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - read_game_creator_agent_runtime_for_session_at(root, agent_id.trim(), session_id.as_deref()) -} - -#[tauri::command] -pub(crate) fn read_game_creator_agent_runtimes( - project_path: String, -) -> Result, String> { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - read_game_creator_agent_runtimes_at(root) -} - #[tauri::command] pub(crate) fn resume_game_creator_agent_runtime_tasks( project_path: String, @@ -1801,81 +1046,6 @@ pub(crate) async fn check_game_creator_llm_config() -> Result Result, String> { - request_automatic_project_name(prompt.trim()) - .await - .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) -} - -#[tauri::command] -pub(crate) async fn polish_local_project_prompt( - prompt: String, - context: Option, -) -> Result { - request_local_project_prompt_polish(prompt.trim(), context.as_deref()) - .await - .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) -} - -/// 安装客户端会话:先传播到本地 Runner,再写本进程会话快照。 -/// -/// Tauri 命令与 Rust 认证会话(`auth_session`)共用这一条路径,避免出现「命令装好了、 -/// 认证链路没装」这种半状态。 -pub(crate) async fn install_client_session_locally( - user_id: String, - access_token: String, - api_base_url: String, - identity_generation: u64, - revision: u64, -) -> Result<(), String> { - tokio::task::spawn_blocking(move || { - validate_platform_session_input( - &user_id, - &access_token, - &api_base_url, - identity_generation, - revision, - )?; - install_external_agent_runner_platform_session( - &user_id, - &access_token, - &api_base_url, - identity_generation, - revision, - )?; - install_platform_session( - &user_id, - &access_token, - &api_base_url, - identity_generation, - revision, - ) - }) - .await - .map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))? -} - -/// 清除客户端会话:先停掉本机会话相关的 app-server,再清 Runner 与本进程快照。 -pub(crate) async fn clear_client_session_locally( - identity_generation: u64, - revision: u64, -) -> Result<(), String> { - tokio::task::spawn_blocking(move || { - shutdown_game_creator_codex_app_servers()?; - clear_external_agent_runner_platform_session(identity_generation, revision)?; - clear_platform_session(identity_generation, revision); - Ok(()) - }) - .await - .map_err(|error| format!("清除本地运行时会话任务意外终止:{error}"))? -} - -// 会话安装/清除只由 Rust 认证会话(`auth_session`)调用 `install_client_session_locally` -// 与 `clear_client_session_locally`;渲染层不再持有写入入口。 - #[tauri::command] pub(crate) fn read_game_creator_app_config() -> Result { game_creator_app_config_view(load_game_creator_app_config()?) @@ -1904,29 +1074,6 @@ pub(crate) fn write_game_creator_app_config( persist_game_creator_app_config(config, overlays, false) } -#[tauri::command] -pub(crate) fn cancel_direct_codex_turn( - project_path: String, - client_turn_id: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "agent.kill")?; - cancel_direct_codex_turn_at(root, client_turn_id.as_deref()) -} - -#[tauri::command] -pub(crate) fn select_game_creator_reasoning_effort( - effort: String, -) -> Result { - let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK - .lock() - .map_err(|_| "配置写入锁不可用")?; - let effort = game_creator_llm_reasoning_effort_name(&effort, "llm.reasoningEffort")?; - let (mut config, overlays) = load_game_creator_app_config_for_write()?; - config.llm.reasoning_effort = effort; - persist_game_creator_app_config(config, overlays, false) -} - fn select_game_creator_model_inner( model_id: String, is_default: bool, @@ -1969,16 +1116,6 @@ pub(crate) fn select_game_creator_model( select_game_creator_model_inner(model_id, is_default, agent_mode) } -#[tauri::command] -pub(crate) async fn discover_game_creator_llm_models( - llm: GameCreatorLlmConfig, -) -> Result, String> { - if !load_game_creator_app_config()?.llm.custom_enabled { - return Err("请先在本地配置中开启 llm.customEnabled".to_string()); - } - fetch_custom_llm_models(&llm).await -} - fn persist_game_creator_app_config( config: GameCreatorAppConfig, overlays: Vec<(PathBuf, serde_json::Value)>, @@ -2036,75 +1173,6 @@ fn update_existing_config_overlay_fields( } } -#[tauri::command] -pub(crate) fn upload_local_asset( - project_path: String, - file_name: String, - media_type: String, - bytes: Vec, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.upload")?; - let _lock = acquire_project_write_lock(root, "asset.upload")?; - advance_agent_runtime_project_revision_locked(root)?; - upload_local_asset_at(root, file_name.trim(), media_type.trim(), &bytes) -} - -#[tauri::command] -pub(crate) fn register_local_asset( - project_path: String, - local_path: String, - kind: String, - media_type: String, - source_kind: String, - canvas_project_id: Option, - resource_id: Option, - asset_object_id: Option, - task_id: Option, - prompt: Option, - model: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - let _lock = acquire_project_write_lock(root, "asset.register")?; - advance_agent_runtime_project_revision_locked(root)?; - register_local_asset_at( - root, - local_path.trim(), - crate::assets::registration_asset_kind(&kind, "asset.register"), - media_type.trim(), - source_kind.trim(), - GameCreationAppAssetSource { - kind: parse_asset_source_kind(source_kind.trim())?, - canvas_project_id: trim_optional_string(canvas_project_id), - resource_id: trim_optional_string(resource_id), - asset_object_id: trim_optional_string(asset_object_id), - task_id: trim_optional_string(task_id), - prompt: trim_optional_string(prompt), - model: trim_optional_string(model), - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - ) -} - -#[tauri::command] -pub(crate) fn update_local_project_resource_classification( - input: UpdateLocalProjectResourceClassificationInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - update_manifest_asset_classification_at( - root, - &input.expected_project_id, - input.expected_project_revision, - &input.asset_id, - &input.category, - input.tags, - ) -} - /// 为一批已登记素材追加标签:整批一次校验、一次 manifest 写入、一次 revision 推进。 /// /// 权限位与单素材分类更新同口径取 `asset.register`(命令包装层只做权限门面, @@ -2126,131 +1194,6 @@ pub(crate) fn add_local_project_resource_tags( ) } -#[tauri::command] -pub(crate) async fn derive_local_project_resource( - input: DeriveLocalProjectResourceInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - derive_local_project_resource_at(input).await -} - -#[tauri::command] -pub(crate) fn list_pending_local_project_resource_edits( - input: ListPendingLocalProjectResourceEditsInput, -) -> Result, String> { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "file.list")?; - list_pending_local_project_resource_edits_at(input) -} - -#[tauri::command] -pub(crate) async fn resume_local_project_resource_edit( - input: ResumeLocalProjectResourceEditInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - resume_local_project_resource_edit_at(input).await -} - -#[tauri::command] -pub(crate) async fn request_local_project_resource_edit_service_identity_confirmation( - input: RequestResourceEditServiceIdentityConfirmationInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - request_resource_edit_service_identity_confirmation_at(input).await -} - -#[tauri::command] -pub(crate) async fn confirm_local_project_resource_edit_service_identity( - input: ConfirmResourceEditServiceIdentityInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - confirm_resource_edit_service_identity_at(input).await -} - -#[tauri::command] -pub(crate) async fn archive_failed_local_project_resource_edit( - input: ArchiveFailedLocalProjectResourceEditInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - archive_failed_local_project_resource_edit_at(input).await -} - -#[tauri::command] -pub(crate) fn normalize_local_project_raster_resource( - input: NormalizeLocalProjectRasterResourceInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - normalize_local_project_raster_resource_at(input) -} - -#[tauri::command] -pub(crate) fn import_canvas_asset( - project_path: String, - local_path: String, - kind: String, - media_type: String, - canvas_project_id: String, - resource_id: Option, - asset_object_id: Option, - task_id: Option, - prompt: Option, - model: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "canvas.asset_import")?; - let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; - advance_agent_runtime_project_revision_locked(root)?; - import_canvas_asset_at( - root, - local_path.trim(), - registration_asset_kind(kind.trim(), "canvas.asset_import"), - media_type.trim(), - canvas_project_id.trim(), - trim_optional_string(resource_id), - trim_optional_string(asset_object_id), - trim_optional_string(task_id), - trim_optional_string(prompt), - trim_optional_string(model), - ) -} - -#[tauri::command] -pub(crate) fn import_canvas_export( - project_path: String, - export_path: String, - canvas_project_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "canvas.export_import")?; - let _lock = acquire_project_write_lock(root, "canvas.export_import")?; - advance_agent_runtime_project_revision_locked(root)?; - import_canvas_export_at( - root, - Path::new(export_path.trim()), - canvas_project_id.trim(), - ) -} - -#[tauri::command] -pub(crate) async fn sync_canvas_project_assets( - project_path: String, - canvas_project_id: String, - api_base_url: Option, - api_key: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "canvas.project_sync")?; - let _lock = acquire_project_write_lock(root, "canvas.project_sync")?; - advance_agent_runtime_project_revision_locked(root)?; - sync_canvas_project_assets_at(root, canvas_project_id.trim(), api_base_url, api_key).await -} - pub(crate) fn import_ui_editor_local_files( project_path: String, source_paths: Vec, @@ -2391,25 +1334,6 @@ fn find_registered_ui_editor_font<'a>( .ok_or_else(|| "字体不是当前项目已登记资产".to_string()) } -#[tauri::command] -pub(crate) fn prepare_ui_editor_project_fonts( - project_path: String, - assets: Vec, -) -> Result, String> { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.list")?; - if assets.len() > UI_EDITOR_FONT_MAX_COUNT { - return Err("一次最多选择 64 个字体面".to_string()); - } - let manifest = read_existing_manifest_for_project(root)?; - let mut fonts = Vec::with_capacity(assets.len()); - for selected in assets { - let asset = find_registered_ui_editor_font(&manifest, &selected.id, &selected.local_path)?; - fonts.push(read_registered_ui_editor_font(root, asset)?.0); - } - Ok(fonts) -} - pub(crate) fn import_ui_editor_local_fonts( project_path: String, source_paths: Vec, @@ -2572,56 +1496,6 @@ pub(crate) fn import_ui_editor_local_fonts( }) } -fn read_checked_ui_editor_font( - project_path: &str, - asset_id: &str, - relative_path: &str, - expected_sha256: &str, -) -> Result<(FontAsset, Vec), String> { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "file.read")?; - let manifest = read_existing_manifest_for_project(root)?; - let asset = find_registered_ui_editor_font(&manifest, asset_id, relative_path)?; - let (font, bytes) = read_registered_ui_editor_font(root, asset)?; - if font.content_sha256 != expected_sha256 { - return Err("字体文件内容与资源摘要不一致".to_string()); - } - Ok((font, bytes)) -} - -#[tauri::command] -pub(crate) fn read_ui_editor_font_bytes( - project_path: String, - asset_id: String, - relative_path: String, - expected_sha256: String, -) -> Result { - read_checked_ui_editor_font( - &project_path, - asset_id.trim(), - relative_path.trim(), - expected_sha256.trim(), - ) - .map(|(_, bytes)| tauri::ipc::Response::new(bytes)) -} - -#[tauri::command] -pub(crate) fn check_ui_editor_font_glyph_coverage( - project_path: String, - asset_id: String, - relative_path: String, - expected_sha256: String, - content: String, -) -> Result { - let (font, bytes) = read_checked_ui_editor_font( - &project_path, - asset_id.trim(), - relative_path.trim(), - expected_sha256.trim(), - )?; - font.has_missing_glyphs(&bytes, &content) -} - #[cfg(test)] mod ui_editor_font_tests { use super::*; @@ -4939,17 +3813,6 @@ async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "canvas.asset_generate")?; - let generated = generate_platform_art_asset_at(root, prompt.trim(), &[]).await?; - Ok(generated.asset) -} - /// 栏目画布底部工具栏使用的「无源生成图片类素材」入参边界。 /// kind 目录由 `canvas_generation::PLATFORM_ART_ASSET_GENERATION_KINDS` 统一维护, /// 这里只收口比例、尺寸与文本长度,且与 agent 侧同一条通道保持同一量级。 @@ -5388,19 +4251,6 @@ mod local_project_asset_generation_tests { } } -#[tauri::command] -pub(crate) fn open_canvas_project( - app: tauri::AppHandle, - canvas_project_id: Option, - editor_base_url: Option, -) -> Result { - let url = build_canvas_project_url(editor_base_url.as_deref(), canvas_project_id.as_deref())?; - app.opener() - .open_url(&url, None::<&str>) - .map_err(|error| format!("打开画板失败:{error}"))?; - Ok(OpenCanvasProjectResult { url }) -} - #[tauri::command] pub(crate) fn run_limited_local_command( project_path: String, @@ -5417,19 +4267,6 @@ pub(crate) fn run_limited_local_command( Ok(result) } -#[tauri::command] -pub(crate) fn append_local_permission_log( - project_path: String, - event: String, - command_id: String, -) -> Result<(), String> { - append_local_permission_log_at( - Path::new(project_path.trim()), - event.trim(), - command_id.trim(), - ) -} - #[tauri::command] pub(crate) fn list_local_project_files( project_path: String, @@ -5439,25 +4276,6 @@ pub(crate) fn list_local_project_files( list_local_project_files_at(root) } -/// 登记当前项目中已经存在、但尚未写入 manifest 的本地资源。 -/// -/// 该入口只接受项目根相对路径;实际文件签名、大小、敏感路径、项目锁和 -/// manifest/revision 更新统一复用 Agent 的受控导入实现。未登记文件在调用前 -/// 只能作为候选展示,不能由前端构造 asset ID。 -#[tauri::command] -pub(crate) async fn import_local_project_image_assets( - project_path: String, - relative_paths: Vec, -) -> Result { - let root = PathBuf::from(project_path.trim()); - tokio::task::spawn_blocking(move || { - import_local_project_assets_for_agent(&root, &relative_paths) - }) - .await - .map_err(|error| format!("项目内资源登记任务意外终止:{error}")) - .and_then(|result| result) -} - #[tauri::command] pub(crate) fn read_local_project_file( project_path: String, @@ -5482,27 +4300,6 @@ pub(crate) fn read_local_project_file( read_local_project_file_at(root, &normalized_path) } -#[tauri::command] -pub(crate) fn save_local_project_asset_file( - input: SaveLocalProjectAssetFileInput, -) -> Result { - save_local_project_asset_file_at(input) -} -#[tauri::command] -pub(crate) async fn read_local_project_image_preview( - preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, - project_path: String, - relative_path: String, - scope_id: String, - request_id: String, -) -> Result { - preview_manager - .run(&scope_id, &request_id, move |cancellation| { - read_local_project_image_preview_at(&project_path, &relative_path, cancellation) - }) - .await -} - pub(crate) fn read_local_project_image_preview_at( project_path: &str, relative_path: &str, @@ -5532,21 +4329,6 @@ pub(crate) fn read_local_project_image_preview_at( load_local_project_image_preview_with_cancellation(root, &normalized_path, cancellation) } -#[tauri::command] -pub(crate) async fn read_local_project_text_preview( - preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, - project_path: String, - relative_path: String, - scope_id: String, - request_id: String, -) -> Result { - preview_manager - .run(&scope_id, &request_id, move |cancellation| { - read_local_project_text_preview_at(&project_path, &relative_path, cancellation) - }) - .await -} - pub(crate) fn read_local_project_text_preview_at( project_path: &str, relative_path: &str, @@ -5598,78 +4380,6 @@ pub(crate) fn read_local_project_text_preview_at( * UI 编辑链路的准入;这里只服务资源画布的卡面预览,且允许非 UTF-8 的二进制变体 * 降级成类型卡(`content: null`),不把「不能预览」报成错误。 */ -#[tauri::command] -pub(crate) async fn read_local_project_structured_preview( - preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, - project_path: String, - relative_path: String, - scope_id: String, - request_id: String, -) -> Result { - preview_manager - .run(&scope_id, &request_id, move |cancellation| { - read_local_project_structured_preview_at(&project_path, &relative_path, cancellation) - }) - .await -} - -pub(crate) fn read_local_project_structured_preview_at( - project_path: &str, - relative_path: &str, - cancellation: &ProjectResourcePreviewScopeCancellation, -) -> Result { - cancellation.check()?; - let root = Path::new(project_path.trim()); - enforce_project_auto_permission_policy(root, "file.read")?; - cancellation.check()?; - let normalized_path = normalize_relative_path(relative_path.trim())?; - let manifest = read_manifest_cached_for_preview(&root.join(".agent/manifest.json"))?; - cancellation.check()?; - let registered_media_type = manifest - .assets - .iter() - .find(|asset| asset.local_path == normalized_path) - .map(|asset| asset.media_type.clone()); - let is_registered_structured = registered_media_type.as_deref().is_some_and(|media_type| { - is_supported_project_structured_resource(&normalized_path, media_type) - }) || manifest.tasks.iter().any(|task| { - task.status == GameCreationAppTaskStatus::Completed - && task.artifacts.iter().any(|path| path == &normalized_path) - && is_supported_project_structured_resource(&normalized_path, "") - }); - if !is_registered_structured { - return Err("只能读取当前项目已登记的引擎资源".to_string()); - } - cancellation.check()?; - load_local_project_structured_preview_with_cancellation( - root, - &normalized_path, - registered_media_type.as_deref().unwrap_or(""), - cancellation, - ) -} - -#[tauri::command] -pub(crate) async fn read_local_project_media_preview( - preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, - project_path: String, - relative_path: String, - category: String, - scope_id: String, - request_id: String, -) -> Result { - preview_manager - .run(&scope_id, &request_id, move |cancellation| { - read_local_project_media_preview_at( - &project_path, - &relative_path, - &category, - cancellation, - ) - }) - .await -} - pub(crate) fn read_local_project_media_preview_at( project_path: &str, relative_path: &str, @@ -5726,14 +4436,6 @@ pub(crate) fn read_local_project_media_preview_at( load_local_project_media_preview_with_cancellation(root, &normalized_path, kind, cancellation) } -#[tauri::command] -pub(crate) fn cancel_local_project_resource_preview_scope( - preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, - scope_id: String, -) -> Result<(), String> { - preview_manager.cancel_scope(&scope_id) -} - type ManualAnalyticsCapture = Option<( crate::analytics::contract::Context, crate::analytics::store::AnalyticsWriter, @@ -5791,79 +4493,6 @@ pub(crate) fn write_local_game_memory( write_local_game_memory_at(root, scope.trim(), &content) } -#[tauri::command] -pub(crate) fn list_game_creator_agent_sessions( - project_path: String, - agent_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - list_game_creator_agent_sessions_at(root, agent_id.trim()) -} - -#[tauri::command] -pub(crate) fn create_game_creator_agent_session( - project_path: String, - agent_id: String, - title: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - // 首轮策划消息可能紧跟项目初始化写入到达;对话保存应等待这段短暂的 - // 项目锁竞争,避免把可恢复的初始化竞态直接显示成保存失败。 - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "conversation.write", - )?; - create_game_creator_agent_session_at(root, agent_id.trim(), title.trim()) -} - -#[tauri::command] -pub(crate) fn fork_game_creator_agent_session( - project_path: String, - agent_id: String, - source_session_id: String, - title: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - fork_game_creator_agent_session_at( - root, - agent_id.trim(), - source_session_id.trim(), - title.trim(), - ) -} - -#[tauri::command] -pub(crate) fn set_active_game_creator_agent_session( - project_path: String, - agent_id: String, - session_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - set_active_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim()) -} - -#[tauri::command] -pub(crate) fn archive_game_creator_agent_session( - project_path: String, - agent_id: String, - session_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - archive_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim()) -} - #[tauri::command] pub(crate) async fn read_local_conversation( project_path: String, @@ -5879,128 +4508,6 @@ pub(crate) async fn read_local_conversation( .map_err(|error| format!("读取项目对话后台任务失败:{error}"))? } -#[tauri::command] -pub(crate) async fn read_direct_project_conversation( - project_path: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - read_direct_project_chat_history_at(root) - }) - .await - .map_err(|error| format!("读取 DirectProject 历史后台任务失败:{error}"))? -} - -#[tauri::command] -pub(crate) fn list_game_creator_direct_active_turns() -> Result, String> { - list_active_turns() -} - -/// 取消一条**还没放行**的待发消息。 -/// -/// 结果分三种:真的从队列移除了(并以 `queue.removed{cancelled}` 告知所有订阅者)、这一条已经被 -/// 放行(正在跑的那一轮就是它,不能按待发消息取消)、队列里本来就没有这个身份。判据只看队列与占用, -/// 不看界面状态——同一份事实源在宿主。 -#[tauri::command] -pub(crate) async fn remove_direct_project_pending_turn( - project_path: String, - client_turn_id: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.write")?; - let thread_id = thread_id_for_project(root); - Ok(remove_pending_turn(&thread_id, client_turn_id.trim())) - }) - .await - .map_err(|error| format!("取消 DirectProject 待发消息后台任务失败:{error}"))? -} - -#[tauri::command] -pub(crate) async fn subscribe_direct_project_thread( - project_path: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - let thread_id = thread_id_for_project(root); - let mut bootstrap = subscribe_thread(&thread_id); - if bootstrap.last_completed_item_id.is_none() { - match read_direct_project_last_item_id_at(root) { - Ok(last_completed_item_id) => { - bootstrap.last_completed_item_id = last_completed_item_id; - } - Err(error) => { - // 订阅已经登记,后续锚点读取失败时也必须回收 native - // subscriber;否则前端拿不到 subscriptionId,无法自行清理。 - unsubscribe_thread(&bootstrap.subscription_id); - return Err(error); - } - } - } - Ok(bootstrap) - }) - .await - .map_err(|error| format!("订阅 DirectProject 线程后台任务失败:{error}"))? -} - -#[tauri::command] -pub(crate) fn consume_direct_project_thread( - subscription_id: String, -) -> Result { - consume_thread(subscription_id.trim()) -} - -#[tauri::command] -pub(crate) fn unsubscribe_direct_project_thread(subscription_id: String) -> Result<(), String> { - unsubscribe_thread(subscription_id.trim()); - Ok(()) -} - -/// 读一屏项目对话历史。 -/// -/// 窗口两端各由一个锚点给出,两者互斥(都传会报错):`before_item_id` 是**旧端**边界(不含 -/// 该条,向后翻页用),`through_item_id` 是**新端**边界(含该条,取 `subscribe` 回执里的 -/// `lastCompletedItemId`,比它更新的条目只从运行态事件来);都不传就是文件尾最近的一屏。 -#[tauri::command] -pub(crate) async fn read_direct_project_history_slice( - project_path: String, - before_item_id: Option, - through_item_id: Option, - limit: Option, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - let anchor = match (before_item_id.as_deref(), through_item_id.as_deref()) { - (Some(_), Some(_)) => { - return Err( - "DirectProject 历史切片只接受一个锚点(beforeItemId / throughItemId)" - .to_string(), - ); - } - (Some(before), None) => DirectProjectHistoryAnchor::Before(before), - (None, Some(through)) => DirectProjectHistoryAnchor::Through(through), - (None, None) => DirectProjectHistoryAnchor::Newest, - }; - let (raw_items, has_more, recorded_at_ms, first_item_id) = - read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?; - let items = thread_items_from_history(root, &raw_items, |item| { - thread_item_identity(item) - .and_then(|identity| recorded_at_ms.get(&identity).copied()) - .unwrap_or_default() - }); - Ok(HistorySlice { - items, - has_more, - first_item_id, - }) - }) - .await - .map_err(|error| format!("读取 DirectProject 历史切片后台任务失败:{error}"))? -} - #[tauri::command] pub(crate) fn append_local_conversation_message( project_path: String, @@ -6029,40 +4536,6 @@ pub(crate) fn append_local_conversation_message( } } -#[tauri::command] -pub(crate) fn append_direct_project_conversation_message( - project_path: String, - message: LocalConversationMessage, - message_id: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.write")?; - let item = - direct_project_local_message_item(&message.role, &message.content, message_id.as_deref())?; - append_direct_project_history_item_at(root, &item)?; - read_direct_project_chat_history_at(root) -} - -#[tauri::command] -pub(crate) fn build_local_project_index( - project_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.index")?; - let _lock = acquire_project_write_lock(root, "project.index")?; - build_local_project_index_at(root) -} - -#[tauri::command] -pub(crate) fn create_local_project_checkpoint( - project_path: String, -) -> Result { - create_local_project_checkpoint_with_capture( - project_path, - crate::analytics::gui::capture_writer_context(), - ) -} - fn create_local_project_checkpoint_with_capture( project_path: String, capture: ManualAnalyticsCapture, @@ -6103,130 +4576,6 @@ pub(crate) fn checkpoint_with_capture_for_test( create_local_project_checkpoint_with_capture(project_path, capture) } -/// 为发布导出试玩包:项目还没有可玩入口时先跑项目自己的 `npm run build`。 -/// -/// 作者只点一次「发布」:已有 `game/index.html` 或 `dist/index.html` 直接打包;只有源码时 -/// 走 `project.verify` 的受控 npm 运行器构建后再打包,失败信息带构建日志尾部。 -#[tauri::command] -pub(crate) async fn export_local_project_package( - project_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.export_package")?; - let _lock = acquire_project_write_lock(root, "project.export_package")?; - advance_agent_runtime_project_revision_locked(root)?; - export_local_project_package_for_publish_at(root).await -} - -/// 把归一化后的发行包落到内容寻址的暂存文件,返回分片续传所需的元数据。 -/// -/// 发布链路从此只把「暂存路径 + 摘要 + 体积」交给渲染进程:整包字节不再经过 -/// WebView IPC,续传时也复用同一个暂存文件(同名同内容)。 -#[tauri::command] -pub(crate) fn prepare_local_project_game_package( - app: tauri::AppHandle, - project_path: String, - package_relative_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.export_package")?; - let payload = read_local_project_export_package_at(root, package_relative_path.trim())?; - let staging_dir = game_package_upload_staging_dir(&app)?; - let mut staged = crate::game_package_upload::stage_game_package_bytes( - &staging_dir, - &payload.package_sha256, - &payload.package_bytes, - )?; - staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX); - Ok(staged) -} - -/// 分片续传上传暂存的发行包;进度通过 `game-package-upload-progress` 事件回传。 -#[tauri::command] -pub(crate) async fn upload_local_project_game_package( - app: tauri::AppHandle, - staging_path: String, - version_id: String, - api_base_url: String, - access_token: String, - idempotency_key: String, -) -> Result { - let staging_dir = game_package_upload_staging_dir(&app)?; - let resolved_path = - crate::game_package_upload::ensure_staging_path_in_dir(&staging_dir, &staging_path)?; - let client = reqwest::Client::builder() - .build() - .map_err(|error| format!("创建上传客户端失败:{error}"))?; - let version_id = version_id.trim().to_string(); - if version_id.is_empty() { - return Err("缺少发行版本标识".to_string()); - } - let emit_handle = app.clone(); - let progress_version_id = version_id.clone(); - crate::game_package_upload::upload_staged_game_package( - &client, - crate::game_package_upload::GamePackageUploadRequest { - staging_path: &resolved_path, - version_id: &version_id, - api_base_url: api_base_url.trim(), - access_token: access_token.trim(), - idempotency_key: idempotency_key.trim(), - session_identity: None, - }, - move |received_bytes, total_bytes| { - let _ = emit_handle.emit( - crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, - crate::game_package_upload::progress_event_payload( - &progress_version_id, - received_bytes, - total_bytes, - ), - ); - }, - ) - .await -} - -fn game_package_upload_staging_dir(app: &tauri::AppHandle) -> Result { - app.path() - .app_data_dir() - .map(|app_data_root| { - crate::game_package_upload::game_package_upload_staging_dir(&app_data_root) - }) - .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) -} - -#[tauri::command] -pub(crate) fn list_local_project_export_packages( - project_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.export_list")?; - list_local_project_export_packages_at(root) -} - -#[tauri::command] -pub(crate) fn diff_local_project_checkpoint( - project_path: String, - checkpoint_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.diff")?; - diff_local_project_checkpoint_at(root, checkpoint_id.trim()) -} - -#[tauri::command] -pub(crate) fn restore_local_project_checkpoint( - project_path: String, - checkpoint_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.restore")?; - let _lock = acquire_project_write_lock(root, "project.restore")?; - advance_agent_runtime_project_revision_locked(root)?; - restore_local_project_checkpoint_at(root, checkpoint_id.trim()) -} - #[tauri::command] pub(crate) fn read_project_permission_policy( project_path: String, @@ -6236,13 +4585,50 @@ pub(crate) fn read_project_permission_policy( read_project_permission_policy_at(root) } -#[tauri::command] -pub(crate) fn write_project_permission_policy( - project_path: String, - policy: ProjectPermissionPolicy, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.policy_write")?; - let _lock = acquire_project_write_lock(root, "project.policy_write")?; - write_project_permission_policy_at(root, policy) +pub(crate) async fn install_client_session_locally( + user_id: String, + access_token: String, + api_base_url: String, + identity_generation: u64, + revision: u64, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + validate_platform_session_input( + &user_id, + &access_token, + &api_base_url, + identity_generation, + revision, + )?; + install_external_agent_runner_platform_session( + &user_id, + &access_token, + &api_base_url, + identity_generation, + revision, + )?; + install_platform_session( + &user_id, + &access_token, + &api_base_url, + identity_generation, + revision, + ) + }) + .await + .map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))? +} + +pub(crate) async fn clear_client_session_locally( + identity_generation: u64, + revision: u64, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + shutdown_game_creator_codex_app_servers()?; + clear_external_agent_runner_platform_session(identity_generation, revision)?; + clear_platform_session(identity_generation, revision); + Ok(()) + }) + .await + .map_err(|error| format!("清除本地运行时会话任务意外终止:{error}"))? } diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs new file mode 100644 index 000000000..7450d5ad1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs @@ -0,0 +1,1637 @@ +use super::*; +use crate::agent::{ + read_direct_project_chat_history_at, read_direct_project_last_item_id_at, + DirectProjectHistoryAnchor, +}; +use tauri_plugin_dialog::DialogExt; +use tauri_plugin_opener::OpenerExt; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorRoleAgentChatStreamEvent { + project_path: String, + agent_id: String, + run_id: String, + status: String, + delta_text: String, + accumulated_text: String, + finish_reason: Option, + session_id: Option, + runtime_status: Option, + runtime_phase: Option, + runtime_summary: Option, + runtime_state: Option, +} + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct OpenCanvasProjectResult { + url: String, +} + +const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64; +// Project naming is an optional homepage enhancement. It must never hold the +// actual project creation flow behind the normal (potentially three-minute) +// generation timeout. + +const AUTOMATIC_PROJECT_NAME_TIMEOUT_MS: u64 = 15_000; + +const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str = + include_str!("../../prompts/automatic-project-name.md"); +// 聊天输入区的 AI 润色复用同一条短文本生成通道:只做一次单轮改写, +// 计费由平台 LLM 路由(/api/llm/chat/completions、/api/llm/responses)侧完成。 + +const LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS: u32 = 2_048; + +const LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT: &str = + include_str!("../../prompts/local-project-prompt-polish.md"); + +async fn request_automatic_project_name(prompt: &str) -> Result, String> { + let user_prompt = build_automatic_project_name_prompt(prompt)?; + let app_config = load_game_creator_app_config()?; + if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { + let reply = tokio::time::timeout( + std::time::Duration::from_millis(AUTOMATIC_PROJECT_NAME_TIMEOUT_MS), + crate::agent::direct_game_creator_home_codex_chat( + AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim().to_string(), + user_prompt, + ), + ) + .await + .map_err(|_| { + format!( + "自动项目命名超时(超过 {} 秒)", + AUTOMATIC_PROJECT_NAME_TIMEOUT_MS / 1_000 + ) + })??; + return Ok(normalize_suggested_project_name(reply.trim())); + } + let mut llm = app_config.llm.clone(); + llm.max_retries = 0; + let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?; + let request = + LlmRunRequest::single_turn(AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim(), user_prompt) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_model(llm.model.clone()) + .with_request_timeout_ms(llm.request_timeout_ms) + .with_max_output_tokens(AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS) + .with_web_search(false); + let response = tokio::time::timeout( + std::time::Duration::from_millis(AUTOMATIC_PROJECT_NAME_TIMEOUT_MS), + client.run(request), + ) + .await + .map_err(|_| { + format!( + "自动项目命名超时(超过 {} 秒)", + AUTOMATIC_PROJECT_NAME_TIMEOUT_MS / 1_000 + ) + })? + .map_err(|error| format!("自动项目命名失败:{error}"))?; + Ok(normalize_suggested_project_name(response.text.trim())) +} + +async fn request_local_project_prompt_polish( + prompt: &str, + context: Option<&str>, +) -> Result { + let user_prompt = build_local_project_prompt_polish_prompt(prompt, context)?; + let app_config = load_game_creator_app_config()?; + if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { + let reply = crate::agent::direct_game_creator_home_codex_chat( + LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim().to_string(), + user_prompt, + ) + .await?; + return normalize_polished_prompt(&reply) + .ok_or_else(|| "AI 润色没有返回可用文本".to_string()); + } + let mut llm = app_config.llm.clone(); + llm.max_retries = 0; + let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?; + let request = LlmRunRequest::single_turn( + LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim(), + user_prompt, + ) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_model(llm.model.clone()) + .with_request_timeout_ms(llm.request_timeout_ms) + .with_max_output_tokens(LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS) + .with_web_search(false); + let response = client + .run(request) + .await + .map_err(|error| format!("AI 润色失败:{error}"))?; + normalize_polished_prompt(&response.text).ok_or_else(|| "AI 润色没有返回可用文本".to_string()) +} + +#[tauri::command] +pub(crate) fn create_automatic_local_game_project( + app: tauri::AppHandle, + name: Option, + planning: Option, + projects_root: Option, +) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); + let result = create_automatic_local_game_project_at( + &resolve_game_project_creation_root(&app, projects_root.as_deref())?, + name.as_deref(), + planning.unwrap_or(false), + ); + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + if planning.unwrap_or(false) { + crate::analytics::contract::CreationSource::HomeDesign + } else { + crate::analytics::contract::CreationSource::HomeGame + }, + None, + ); + } + result +} + +#[tauri::command] +pub(crate) fn init_local_game_project( + project_path: String, + project_id: String, + name: String, +) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.create")?; + let _lock = acquire_project_write_lock(root, "project.create")?; + let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); + let result = init_local_game_project_at(root, project_id.trim(), name.trim()); + if new_project { + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::SelectedDirectory, + None, + ); + } + } + result +} + +#[tauri::command] +pub(crate) fn import_local_cocos_project( + project_path: String, + project_id: String, + name: String, +) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.create")?; + if discover_local_cocos_project_root(root)?.is_none() { + return Err( + "所选目录不是有效的 Cocos Creator 项目(需要 package.json.creator.version 和 assets/)" + .to_string(), + ); + } + let _lock = acquire_project_write_lock(root, "project.create")?; + let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); + let result = import_local_cocos_project_at(root, project_id.trim(), name.trim()); + if new_project { + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::SelectedDirectory, + None, + ); + } + } + result +} + +#[tauri::command] +pub(crate) fn import_local_unity_project( + project_path: String, + project_id: String, + name: String, +) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.create")?; + if discover_local_unity_project_root(root)?.is_none() { + return Err("所选目录不是有效的 Unity 项目".to_string()); + } + let _lock = acquire_project_write_lock(root, "project.create")?; + let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); + let result = import_local_unity_project_at(root, project_id.trim(), name.trim()); + if new_project { + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::SelectedDirectory, + None, + ); + } + } + result +} + +#[tauri::command] +pub(crate) async fn inspect_local_project_directory( + project_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || inspect_local_project_directory_sync(project_path)) + .await + .map_err(|error| format!("检查项目目录后台任务失败:{error}"))? +} + +/// 目录选择器标题:调用方只能给短标题,其余(超长、含控制字符、空白)一律回退默认文案。 +fn pick_project_directory_title(title: Option<&str>) -> &str { + const MAX_TITLE_CHARS: usize = 24; + title + .map(str::trim) + .filter(|value| { + !value.is_empty() + && value.chars().count() <= MAX_TITLE_CHARS + && !value.chars().any(char::is_control) + }) + .unwrap_or("选择游戏项目目录") +} + +#[tauri::command] +pub(crate) async fn pick_local_project_directory( + app: tauri::AppHandle, + initial_path: Option, + title: Option, +) -> Result, String> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let mut dialog = app + .dialog() + .file() + .set_title(pick_project_directory_title(title.as_deref())); + if let Some(initial_path) = initial_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + { + let initial_path = Path::new(initial_path); + if initial_path.is_absolute() && !project_path_has_control_chars(initial_path) { + let starting_directory = closest_existing_project_picker_directory(initial_path); + if let Some(starting_directory) = starting_directory { + dialog = dialog.set_directory(starting_directory); + } + } + } + if let Some(window) = app.get_webview_window("client") { + dialog = dialog.set_parent(&window); + } + dialog.pick_folder(move |path| { + let _ = sender.send(path); + }); + let Some(path) = receiver + .await + .map_err(|_| "项目目录选择器意外关闭".to_string())? + else { + return Ok(None); + }; + let path = path + .into_path() + .map_err(|error| format!("读取项目目录失败:{error}"))?; + #[cfg(windows)] + crate::register_game_creator_user_selected_path(&path, true); + // The native picker is the explicit user-selection boundary. Prepare the + // selected root before returning it so inspect/create/open never races the + // first ACL read. + if let Err(error) = + crate::prepare_game_creator_project_root_for_read(&path, true, "用户选择项目目录") + { + #[cfg(windows)] + crate::revoke_game_creator_user_selected_path(&path); + return Err(error); + } + Ok(Some(path.to_string_lossy().into_owned())) +} + +#[tauri::command] +pub(crate) fn open_local_project_directory( + app: tauri::AppHandle, + project_path: String, +) -> Result<(), String> { + let path = validated_local_project_directory_path(project_path.trim())?; + app.opener() + .open_path(path.to_string_lossy().into_owned(), None::<&str>) + .map_err(|error| format!("打开项目目录失败:{error}")) +} + +#[tauri::command] +pub(crate) async fn get_local_game_manifest( + project_path: String, + command_id: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + get_local_game_manifest_sync(project_path, command_id) + }) + .await + .map_err(|error| format!("读取项目 manifest 后台任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn read_local_project_resource_graph( + project_path: String, + expected_project_id: String, + resources: Vec, +) -> Result { + let root = validated_local_project_directory_path(project_path.trim())?; + enforce_project_auto_permission_policy(&root, "asset.list")?; + read_project_resource_graph_at(&root, expected_project_id.trim(), resources) +} + +/// 读取引用了某个素材的项目版本,供删除弹窗提示「被哪些版本使用」。 +#[tauri::command] +pub(crate) fn read_local_project_asset_references( + input: ReadLocalProjectAssetReferencesInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + read_manifest_asset_references_at(root, &input.asset_id) +} + +/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。 +/// +/// 素材不可变:只摘掉 manifest 登记,磁盘文件保留。 +#[tauri::command] +pub(crate) fn delete_local_project_asset( + input: DeleteLocalProjectAssetInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + delete_manifest_asset_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.asset_id, + input.delete_referenced_versions, + ) +} + +/// 读取某个版本引用的源素材可用的替换候选,并给出后端权威的三项兼容性结论。 +/// +/// 只读:不改 manifest、不推进 revision。候选渲染但禁用,不在前端重算判据。 +#[tauri::command] +pub(crate) fn read_local_project_version_resource_replacement_candidates( + input: ReadLocalProjectVersionReplacementCandidatesInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + read_local_project_version_replacement_candidates_at( + root, + &input.source_version_id, + &input.source_resource_id, + ) +} + +/// 用另一个已登记素材替换某个版本引用的素材:改 manifest 绑定,并**追加下一迭代版本**。 +/// +/// 可运行版本不可变(既有版本记录一个字节都不改)、不动资源文件、不建文件副本; +/// 三项兼容性必须同时为 true,否则拒绝并说明哪一项不等。CAS 失败时 manifest 与 revision 都不变。 +#[tauri::command] +pub(crate) fn replace_local_project_version_resource( + input: ReplaceLocalProjectVersionResourceInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + replace_local_project_version_resource_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.source_version_id, + &input.source_resource_id, + &input.replacement_resource_id, + ) +} + +/// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。 +/// +/// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败 +/// 时把文件改回原名,不留半成品。与删除 / 分类更新同口径:持项目写锁后按 `expectedProjectId` +/// 与当前 revision 做 CAS,失败时磁盘、manifest 与 revision 都不动。 +#[tauri::command] +pub(crate) fn rename_local_project_asset( + input: RenameLocalProjectAssetInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + rename_local_project_asset_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.asset_id, + &input.new_file_name, + ) +} + +#[tauri::command] +pub(crate) async fn control_agent_run( + app: tauri::AppHandle, + project_path: String, + action: String, + detail: Option, +) -> Result { + let root = Path::new(project_path.trim()); + let command_id = match action.trim() { + "status" => "agent.run_status", + "kill" => "agent.kill", + "retry" => "agent.retry", + "resume" => "agent.resume", + _ => "agent.run_status", + }; + enforce_project_permission_policy(root, command_id)?; + if matches!(action.trim(), "retry" | "resume") { + enforce_project_permission_policy(root, "game.generate_draft")?; + } + let _lock = if command_id == "agent.run_status" { + None + } else { + Some(acquire_project_write_lock(root, command_id)?) + }; + control_agent_run_at( + root, + action.trim(), + detail + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()), + Some(&AgentProgressEmitter::new(&app, project_path.trim())), + ) + .await +} + +#[tauri::command] +pub(crate) async fn generate_local_game_draft( + app: tauri::AppHandle, + project_path: String, + prompt: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "game.generate_draft")?; + let _lock = acquire_project_write_lock(root, "game.generate_draft")?; + advance_agent_runtime_project_revision_locked(root)?; + generate_local_game_draft_at( + root, + prompt.trim(), + Some(&AgentProgressEmitter::new(&app, project_path.trim())), + ) + .await +} + +#[tauri::command] +pub(crate) async fn chat_with_game_creator_agent( + project_path: String, + prompt: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + chat_with_game_creator_agent_at(root, prompt.trim()).await +} + +#[tauri::command] +pub(crate) async fn chat_with_game_creator_role_agent_stream( + app: tauri::AppHandle, + project_path: String, + agent_id: String, + session_id: Option, + prompt: String, + run_id: String, +) -> Result { + let project_path = project_path.trim().to_string(); + let agent_id = normalize_game_creator_runtime_agent_id(agent_id.trim())?; + let run_id = run_id.trim().to_string(); + let root = Path::new(project_path.as_str()); + let session_id = + resolve_agent_conversation_session_id_at(root, &agent_id, session_id.as_deref(), true)?; + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)? + .ok_or_else(|| format!("Agent 正在执行其他前台或后台任务:{agent_id}"))?; + let emit_app = app.clone(); + let event_project_path = project_path.clone(); + let event_agent_id = agent_id.clone(); + let event_run_id = run_id.clone(); + let command_result = async { + let mut runtime_state = start_game_creator_agent_runtime_turn_for_session_at( + root, + agent_id.as_str(), + Some(&session_id), + prompt.trim(), + &run_id, + )?; + runtime_state = advance_game_creator_agent_runtime_turn_at( + root, + runtime_state, + "llm", + "请求 Agent LLM", + "已读取项目上下文,正在让 Agent 独立推理。", + )?; + let mut streaming_runtime_state = runtime_state.clone(); + streaming_runtime_state.current_action = "正在接收 Agent 回复".to_string(); + let _ = app.emit( + "game-creator-role-agent-chat-stream", + GameCreatorRoleAgentChatStreamEvent { + project_path: event_project_path.clone(), + agent_id: event_agent_id.clone(), + run_id: event_run_id.clone(), + status: "started".to_string(), + delta_text: String::new(), + accumulated_text: String::new(), + finish_reason: None, + session_id: Some(runtime_state.session_id.clone()), + runtime_status: Some(runtime_state.status.clone()), + runtime_phase: Some(runtime_state.phase.clone()), + runtime_summary: Some(runtime_state.current_action.clone()), + runtime_state: Some(runtime_state.clone()), + }, + ); + let result = chat_with_game_creator_role_agent_stream_for_session_at( + root, + agent_id.as_str(), + Some(&session_id), + prompt.trim(), + |delta| { + let _ = emit_app.emit( + "game-creator-role-agent-chat-stream", + GameCreatorRoleAgentChatStreamEvent { + project_path: event_project_path.clone(), + agent_id: event_agent_id.clone(), + run_id: event_run_id.clone(), + status: "delta".to_string(), + delta_text: delta.delta_text.clone(), + accumulated_text: delta.accumulated_text.clone(), + finish_reason: delta.finish_reason.clone(), + session_id: Some(streaming_runtime_state.session_id.clone()), + runtime_status: Some("running".to_string()), + runtime_phase: Some("llm".to_string()), + runtime_summary: Some("正在接收 Agent 回复".to_string()), + runtime_state: Some(streaming_runtime_state.clone()), + }, + ); + }, + ) + .await; + match result { + Ok(reply) => { + let completed_runtime = finish_game_creator_agent_runtime_turn_at( + root, + runtime_state, + &reply.reply_text, + )?; + let _ = app.emit( + "game-creator-role-agent-chat-stream", + GameCreatorRoleAgentChatStreamEvent { + project_path: project_path.clone(), + agent_id: agent_id.clone(), + run_id: run_id.clone(), + status: "completed".to_string(), + delta_text: String::new(), + accumulated_text: reply.reply_text.clone(), + finish_reason: None, + session_id: Some(completed_runtime.session_id.clone()), + runtime_status: Some(completed_runtime.status.clone()), + runtime_phase: Some(completed_runtime.phase.clone()), + runtime_summary: Some(completed_runtime.current_action.clone()), + runtime_state: Some(completed_runtime), + }, + ); + Ok(reply) + } + Err(error) => { + let failed_runtime = + fail_game_creator_agent_runtime_turn_at(root, runtime_state, &error).ok(); + let _ = app.emit( + "game-creator-role-agent-chat-stream", + GameCreatorRoleAgentChatStreamEvent { + project_path: project_path.clone(), + agent_id: agent_id.clone(), + run_id: run_id.clone(), + status: "failed".to_string(), + delta_text: String::new(), + accumulated_text: String::new(), + finish_reason: None, + session_id: failed_runtime + .as_ref() + .map(|runtime| runtime.session_id.clone()), + runtime_status: failed_runtime + .as_ref() + .map(|runtime| runtime.status.clone()), + runtime_phase: failed_runtime.as_ref().map(|runtime| runtime.phase.clone()), + runtime_summary: failed_runtime + .as_ref() + .map(|runtime| runtime.current_action.clone()), + runtime_state: failed_runtime, + }, + ); + Err(error) + } + } + } + .await; + spawn_next_game_creator_agent_background_task_drain_with_lock(root, &agent_id, runtime_lock); + match command_result { + Ok(reply) => Ok(reply), + Err(error) => Err(error), + } +} + +#[tauri::command] +pub(crate) fn start_game_creator_supervisor_runtime_task( + project_path: String, + session_id: Option, + task: String, + run_id: String, + run_profile: Option, + source: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + enforce_project_permission_policy(root, "agent.run_status")?; + let run_profile = run_profile + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(AGENT_RUNTIME_RUN_PROFILE_STANDARD); + let source = source + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE); + if !agent_runtime_supervisor_source_is_trusted(source) { + return Err("Project Supervisor 提交 source 不受信任".to_string()); + } + start_game_creator_supervisor_background_task_for_session_at( + root, + session_id.as_deref(), + task.trim(), + run_id.trim(), + source, + run_profile, + ) +} + +#[tauri::command] +pub(crate) async fn compact_game_creator_agent_runtime_context( + project_path: String, + agent_id: String, + session_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "agent.compact")?; + if external_agent_runner_enabled() && !external_agent_runner_is_server_process() { + compact_external_agent_runner_context(root, agent_id.trim(), session_id.as_deref()) + } else { + compact_game_creator_agent_runtime_session_at(root, agent_id.trim(), session_id.as_deref()) + .await + } +} + +#[tauri::command] +pub(crate) fn cancel_game_creator_agent_runtime_task( + project_path: String, + agent_id: String, + run_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + enforce_project_permission_policy(root, "agent.run_status")?; + cancel_game_creator_agent_runtime_task_at(root, agent_id.trim(), run_id.trim()) +} + +#[tauri::command] +pub(crate) fn answer_game_creator_agent_runtime_user_input( + project_path: String, + agent_id: String, + run_id: String, + action_id: String, + request_id: String, + response_id: String, + answers: BTreeMap, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + enforce_project_permission_policy(root, "agent.run_status")?; + enforce_project_permission_policy(root, "agent.resume")?; + answer_game_creator_agent_runtime_user_input_at( + root, + agent_id.trim(), + run_id.trim(), + action_id.trim(), + request_id.trim(), + response_id.trim(), + answers, + ) +} + +#[tauri::command] +pub(crate) fn read_game_creator_agent_runtime( + project_path: String, + agent_id: String, + session_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_game_creator_agent_runtime_for_session_at(root, agent_id.trim(), session_id.as_deref()) +} + +#[tauri::command] +pub(crate) fn read_game_creator_agent_runtimes( + project_path: String, +) -> Result, String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_game_creator_agent_runtimes_at(root) +} + +#[tauri::command] +pub(crate) async fn suggest_automatic_project_name( + prompt: String, +) -> Result, String> { + request_automatic_project_name(prompt.trim()) + .await + .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) +} + +#[tauri::command] +pub(crate) async fn polish_local_project_prompt( + prompt: String, + context: Option, +) -> Result { + request_local_project_prompt_polish(prompt.trim(), context.as_deref()) + .await + .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) +} + +#[tauri::command] +pub(crate) fn cancel_direct_codex_turn( + project_path: String, + client_turn_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "agent.kill")?; + cancel_direct_codex_turn_at(root, client_turn_id.as_deref()) +} + +#[tauri::command] +pub(crate) fn select_game_creator_reasoning_effort( + effort: String, +) -> Result { + let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK + .lock() + .map_err(|_| "配置写入锁不可用")?; + let effort = game_creator_llm_reasoning_effort_name(&effort, "llm.reasoningEffort")?; + let (mut config, overlays) = load_game_creator_app_config_for_write()?; + config.llm.reasoning_effort = effort; + persist_game_creator_app_config(config, overlays, false) +} + +#[tauri::command] +pub(crate) async fn discover_game_creator_llm_models( + llm: GameCreatorLlmConfig, +) -> Result, String> { + if !load_game_creator_app_config()?.llm.custom_enabled { + return Err("请先在本地配置中开启 llm.customEnabled".to_string()); + } + fetch_custom_llm_models(&llm).await +} + +#[tauri::command] +pub(crate) fn upload_local_asset( + project_path: String, + file_name: String, + media_type: String, + bytes: Vec, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.upload")?; + let _lock = acquire_project_write_lock(root, "asset.upload")?; + advance_agent_runtime_project_revision_locked(root)?; + upload_local_asset_at(root, file_name.trim(), media_type.trim(), &bytes) +} + +#[tauri::command] +pub(crate) fn register_local_asset( + project_path: String, + local_path: String, + kind: String, + media_type: String, + source_kind: String, + canvas_project_id: Option, + resource_id: Option, + asset_object_id: Option, + task_id: Option, + prompt: Option, + model: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + let _lock = acquire_project_write_lock(root, "asset.register")?; + advance_agent_runtime_project_revision_locked(root)?; + register_local_asset_at( + root, + local_path.trim(), + crate::assets::registration_asset_kind(&kind, "asset.register"), + media_type.trim(), + source_kind.trim(), + GameCreationAppAssetSource { + kind: parse_asset_source_kind(source_kind.trim())?, + canvas_project_id: trim_optional_string(canvas_project_id), + resource_id: trim_optional_string(resource_id), + asset_object_id: trim_optional_string(asset_object_id), + task_id: trim_optional_string(task_id), + prompt: trim_optional_string(prompt), + model: trim_optional_string(model), + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) +} + +#[tauri::command] +pub(crate) fn update_local_project_resource_classification( + input: UpdateLocalProjectResourceClassificationInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + update_manifest_asset_classification_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.asset_id, + &input.category, + input.tags, + ) +} + +#[tauri::command] +pub(crate) async fn derive_local_project_resource( + input: DeriveLocalProjectResourceInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + derive_local_project_resource_at(input).await +} + +#[tauri::command] +pub(crate) fn list_pending_local_project_resource_edits( + input: ListPendingLocalProjectResourceEditsInput, +) -> Result, String> { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "file.list")?; + list_pending_local_project_resource_edits_at(input) +} + +#[tauri::command] +pub(crate) async fn resume_local_project_resource_edit( + input: ResumeLocalProjectResourceEditInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + resume_local_project_resource_edit_at(input).await +} + +#[tauri::command] +pub(crate) async fn request_local_project_resource_edit_service_identity_confirmation( + input: RequestResourceEditServiceIdentityConfirmationInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + request_resource_edit_service_identity_confirmation_at(input).await +} + +#[tauri::command] +pub(crate) async fn confirm_local_project_resource_edit_service_identity( + input: ConfirmResourceEditServiceIdentityInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + confirm_resource_edit_service_identity_at(input).await +} + +#[tauri::command] +pub(crate) async fn archive_failed_local_project_resource_edit( + input: ArchiveFailedLocalProjectResourceEditInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + archive_failed_local_project_resource_edit_at(input).await +} + +#[tauri::command] +pub(crate) fn normalize_local_project_raster_resource( + input: NormalizeLocalProjectRasterResourceInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + normalize_local_project_raster_resource_at(input) +} + +#[tauri::command] +pub(crate) fn import_canvas_asset( + project_path: String, + local_path: String, + kind: String, + media_type: String, + canvas_project_id: String, + resource_id: Option, + asset_object_id: Option, + task_id: Option, + prompt: Option, + model: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.asset_import")?; + let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; + advance_agent_runtime_project_revision_locked(root)?; + import_canvas_asset_at( + root, + local_path.trim(), + registration_asset_kind(kind.trim(), "canvas.asset_import"), + media_type.trim(), + canvas_project_id.trim(), + trim_optional_string(resource_id), + trim_optional_string(asset_object_id), + trim_optional_string(task_id), + trim_optional_string(prompt), + trim_optional_string(model), + ) +} + +#[tauri::command] +pub(crate) fn import_canvas_export( + project_path: String, + export_path: String, + canvas_project_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.export_import")?; + let _lock = acquire_project_write_lock(root, "canvas.export_import")?; + advance_agent_runtime_project_revision_locked(root)?; + import_canvas_export_at( + root, + Path::new(export_path.trim()), + canvas_project_id.trim(), + ) +} + +#[tauri::command] +pub(crate) async fn sync_canvas_project_assets( + project_path: String, + canvas_project_id: String, + api_base_url: Option, + api_key: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.project_sync")?; + let _lock = acquire_project_write_lock(root, "canvas.project_sync")?; + advance_agent_runtime_project_revision_locked(root)?; + sync_canvas_project_assets_at(root, canvas_project_id.trim(), api_base_url, api_key).await +} + +#[tauri::command] +pub(crate) fn prepare_ui_editor_project_fonts( + project_path: String, + assets: Vec, +) -> Result, String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + if assets.len() > UI_EDITOR_FONT_MAX_COUNT { + return Err("一次最多选择 64 个字体面".to_string()); + } + let manifest = read_existing_manifest_for_project(root)?; + let mut fonts = Vec::with_capacity(assets.len()); + for selected in assets { + let asset = find_registered_ui_editor_font(&manifest, &selected.id, &selected.local_path)?; + fonts.push(read_registered_ui_editor_font(root, asset)?.0); + } + Ok(fonts) +} + +fn read_checked_ui_editor_font( + project_path: &str, + asset_id: &str, + relative_path: &str, + expected_sha256: &str, +) -> Result<(FontAsset, Vec), String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "file.read")?; + let manifest = read_existing_manifest_for_project(root)?; + let asset = find_registered_ui_editor_font(&manifest, asset_id, relative_path)?; + let (font, bytes) = read_registered_ui_editor_font(root, asset)?; + if font.content_sha256 != expected_sha256 { + return Err("字体文件内容与资源摘要不一致".to_string()); + } + Ok((font, bytes)) +} + +#[tauri::command] +pub(crate) fn read_ui_editor_font_bytes( + project_path: String, + asset_id: String, + relative_path: String, + expected_sha256: String, +) -> Result { + read_checked_ui_editor_font( + &project_path, + asset_id.trim(), + relative_path.trim(), + expected_sha256.trim(), + ) + .map(|(_, bytes)| tauri::ipc::Response::new(bytes)) +} + +#[tauri::command] +pub(crate) fn check_ui_editor_font_glyph_coverage( + project_path: String, + asset_id: String, + relative_path: String, + expected_sha256: String, + content: String, +) -> Result { + let (font, bytes) = read_checked_ui_editor_font( + &project_path, + asset_id.trim(), + relative_path.trim(), + expected_sha256.trim(), + )?; + font.has_missing_glyphs(&bytes, &content) +} + +#[tauri::command] +pub(crate) async fn generate_platform_art_asset( + project_path: String, + prompt: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.asset_generate")?; + let generated = generate_platform_art_asset_at(root, prompt.trim(), &[]).await?; + Ok(generated.asset) +} + +#[tauri::command] +pub(crate) fn open_canvas_project( + app: tauri::AppHandle, + canvas_project_id: Option, + editor_base_url: Option, +) -> Result { + let url = build_canvas_project_url(editor_base_url.as_deref(), canvas_project_id.as_deref())?; + app.opener() + .open_url(&url, None::<&str>) + .map_err(|error| format!("打开画板失败:{error}"))?; + Ok(OpenCanvasProjectResult { url }) +} + +#[tauri::command] +pub(crate) fn append_local_permission_log( + project_path: String, + event: String, + command_id: String, +) -> Result<(), String> { + append_local_permission_log_at( + Path::new(project_path.trim()), + event.trim(), + command_id.trim(), + ) +} + +/// 登记当前项目中已经存在、但尚未写入 manifest 的本地资源。 +/// +/// 该入口只接受项目根相对路径;实际文件签名、大小、敏感路径、项目锁和 +/// manifest/revision 更新统一复用 Agent 的受控导入实现。未登记文件在调用前 +/// 只能作为候选展示,不能由前端构造 asset ID。 +#[tauri::command] +pub(crate) async fn import_local_project_image_assets( + project_path: String, + relative_paths: Vec, +) -> Result { + let root = PathBuf::from(project_path.trim()); + tokio::task::spawn_blocking(move || { + import_local_project_assets_for_agent(&root, &relative_paths) + }) + .await + .map_err(|error| format!("项目内资源登记任务意外终止:{error}")) + .and_then(|result| result) +} + +#[tauri::command] +pub(crate) fn save_local_project_asset_file( + input: SaveLocalProjectAssetFileInput, +) -> Result { + save_local_project_asset_file_at(input) +} + +#[tauri::command] +pub(crate) async fn read_local_project_image_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + project_path: String, + relative_path: String, + scope_id: String, + request_id: String, +) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_image_preview_at(&project_path, &relative_path, cancellation) + }) + .await +} + +#[tauri::command] +pub(crate) async fn read_local_project_text_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + project_path: String, + relative_path: String, + scope_id: String, + request_id: String, +) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_text_preview_at(&project_path, &relative_path, cancellation) + }) + .await +} + +#[tauri::command] +pub(crate) async fn read_local_project_structured_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + project_path: String, + relative_path: String, + scope_id: String, + request_id: String, +) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_structured_preview_at(&project_path, &relative_path, cancellation) + }) + .await +} + +pub(crate) fn read_local_project_structured_preview_at( + project_path: &str, + relative_path: &str, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; + let root = Path::new(project_path.trim()); + enforce_project_auto_permission_policy(root, "file.read")?; + cancellation.check()?; + let normalized_path = normalize_relative_path(relative_path.trim())?; + let manifest = read_manifest_cached_for_preview(&root.join(".agent/manifest.json"))?; + cancellation.check()?; + let registered_media_type = manifest + .assets + .iter() + .find(|asset| asset.local_path == normalized_path) + .map(|asset| asset.media_type.clone()); + let is_registered_structured = registered_media_type.as_deref().is_some_and(|media_type| { + is_supported_project_structured_resource(&normalized_path, media_type) + }) || manifest.tasks.iter().any(|task| { + task.status == GameCreationAppTaskStatus::Completed + && task.artifacts.iter().any(|path| path == &normalized_path) + && is_supported_project_structured_resource(&normalized_path, "") + }); + if !is_registered_structured { + return Err("只能读取当前项目已登记的引擎资源".to_string()); + } + cancellation.check()?; + load_local_project_structured_preview_with_cancellation( + root, + &normalized_path, + registered_media_type.as_deref().unwrap_or(""), + cancellation, + ) +} + +#[tauri::command] +pub(crate) async fn read_local_project_media_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + project_path: String, + relative_path: String, + category: String, + scope_id: String, + request_id: String, +) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_media_preview_at( + &project_path, + &relative_path, + &category, + cancellation, + ) + }) + .await +} + +#[tauri::command] +pub(crate) fn cancel_local_project_resource_preview_scope( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + scope_id: String, +) -> Result<(), String> { + preview_manager.cancel_scope(&scope_id) +} + +#[tauri::command] +pub(crate) fn list_game_creator_agent_sessions( + project_path: String, + agent_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + list_game_creator_agent_sessions_at(root, agent_id.trim()) +} + +#[tauri::command] +pub(crate) fn create_game_creator_agent_session( + project_path: String, + agent_id: String, + title: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + // 首轮策划消息可能紧跟项目初始化写入到达;对话保存应等待这段短暂的 + // 项目锁竞争,避免把可恢复的初始化竞态直接显示成保存失败。 + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "conversation.write", + )?; + create_game_creator_agent_session_at(root, agent_id.trim(), title.trim()) +} + +#[tauri::command] +pub(crate) fn fork_game_creator_agent_session( + project_path: String, + agent_id: String, + source_session_id: String, + title: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + let _lock = acquire_project_write_lock(root, "conversation.write")?; + fork_game_creator_agent_session_at( + root, + agent_id.trim(), + source_session_id.trim(), + title.trim(), + ) +} + +#[tauri::command] +pub(crate) fn set_active_game_creator_agent_session( + project_path: String, + agent_id: String, + session_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + let _lock = acquire_project_write_lock(root, "conversation.write")?; + set_active_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim()) +} + +#[tauri::command] +pub(crate) fn archive_game_creator_agent_session( + project_path: String, + agent_id: String, + session_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + let _lock = acquire_project_write_lock(root, "conversation.write")?; + archive_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim()) +} + +#[tauri::command] +pub(crate) async fn read_direct_project_conversation( + project_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_direct_project_chat_history_at(root) + }) + .await + .map_err(|error| format!("读取 DirectProject 历史后台任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn list_game_creator_direct_active_turns() -> Result, String> { + list_active_turns() +} + +#[tauri::command] +pub(crate) async fn subscribe_direct_project_thread( + project_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + let thread_id = thread_id_for_project(root); + let mut bootstrap = subscribe_thread(&thread_id); + if bootstrap.last_completed_item_id.is_none() { + match read_direct_project_last_item_id_at(root) { + Ok(last_completed_item_id) => { + bootstrap.last_completed_item_id = last_completed_item_id; + } + Err(error) => { + // 订阅已经登记,后续锚点读取失败时也必须回收 native + // subscriber;否则前端拿不到 subscriptionId,无法自行清理。 + unsubscribe_thread(&bootstrap.subscription_id); + return Err(error); + } + } + } + Ok(bootstrap) + }) + .await + .map_err(|error| format!("订阅 DirectProject 线程后台任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn consume_direct_project_thread( + subscription_id: String, +) -> Result { + consume_thread(subscription_id.trim()) +} + +/// 读一屏项目对话历史。 +/// +/// 窗口两端各由一个锚点给出,两者互斥(都传会报错):`before_item_id` 是**旧端**边界(不含 +/// 该条,向后翻页用),`through_item_id` 是**新端**边界(含该条,取 `subscribe` 回执里的 +/// `lastCompletedItemId`,比它更新的条目只从运行态事件来);都不传就是文件尾最近的一屏。 +#[tauri::command] +pub(crate) async fn read_direct_project_history_slice( + project_path: String, + before_item_id: Option, + through_item_id: Option, + limit: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + let anchor = match (before_item_id.as_deref(), through_item_id.as_deref()) { + (Some(_), Some(_)) => { + return Err( + "DirectProject 历史切片只接受一个锚点(beforeItemId / throughItemId)" + .to_string(), + ); + } + (Some(before), None) => DirectProjectHistoryAnchor::Before(before), + (None, Some(through)) => DirectProjectHistoryAnchor::Through(through), + (None, None) => DirectProjectHistoryAnchor::Newest, + }; + let (raw_items, has_more, recorded_at_ms, first_item_id) = + read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?; + let items = thread_items_from_history(root, &raw_items, |item| { + thread_item_identity(item) + .and_then(|identity| recorded_at_ms.get(&identity).copied()) + .unwrap_or_default() + }); + Ok(HistorySlice { + items, + has_more, + first_item_id, + }) + }) + .await + .map_err(|error| format!("读取 DirectProject 历史切片后台任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn append_direct_project_conversation_message( + project_path: String, + message: LocalConversationMessage, + message_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.write")?; + let item = + direct_project_local_message_item(&message.role, &message.content, message_id.as_deref())?; + append_direct_project_history_item_at(root, &item)?; + read_direct_project_chat_history_at(root) +} + +#[tauri::command] +pub(crate) fn build_local_project_index( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.index")?; + let _lock = acquire_project_write_lock(root, "project.index")?; + build_local_project_index_at(root) +} + +#[tauri::command] +pub(crate) fn create_local_project_checkpoint( + project_path: String, +) -> Result { + create_local_project_checkpoint_with_capture( + project_path, + crate::analytics::gui::capture_writer_context(), + ) +} + +/// 为发布导出试玩包:项目还没有可玩入口时先跑项目自己的 `npm run build`。 +/// +/// 作者只点一次「发布」:已有 `game/index.html` 或 `dist/index.html` 直接打包;只有源码时 +/// 走 `project.verify` 的受控 npm 运行器构建后再打包,失败信息带构建日志尾部。 +#[tauri::command] +pub(crate) async fn export_local_project_package( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.export_package")?; + let _lock = acquire_project_write_lock(root, "project.export_package")?; + advance_agent_runtime_project_revision_locked(root)?; + export_local_project_package_for_publish_at(root).await +} + +/// 把归一化后的发行包落到内容寻址的暂存文件,返回分片续传所需的元数据。 +/// +/// 发布链路从此只把「暂存路径 + 摘要 + 体积」交给渲染进程:整包字节不再经过 +/// WebView IPC,续传时也复用同一个暂存文件(同名同内容)。 +#[tauri::command] +pub(crate) fn prepare_local_project_game_package( + app: tauri::AppHandle, + project_path: String, + package_relative_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.export_package")?; + let payload = read_local_project_export_package_at(root, package_relative_path.trim())?; + let staging_dir = game_package_upload_staging_dir(&app)?; + let mut staged = crate::game_package_upload::stage_game_package_bytes( + &staging_dir, + &payload.package_sha256, + &payload.package_bytes, + )?; + staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX); + Ok(staged) +} + +/// 分片续传上传暂存的发行包;进度通过 `game-package-upload-progress` 事件回传。 +#[tauri::command] +pub(crate) async fn upload_local_project_game_package( + app: tauri::AppHandle, + staging_path: String, + version_id: String, + api_base_url: String, + access_token: String, + idempotency_key: String, +) -> Result { + let staging_dir = game_package_upload_staging_dir(&app)?; + let resolved_path = + crate::game_package_upload::ensure_staging_path_in_dir(&staging_dir, &staging_path)?; + let client = reqwest::Client::builder() + .build() + .map_err(|error| format!("创建上传客户端失败:{error}"))?; + let version_id = version_id.trim().to_string(); + if version_id.is_empty() { + return Err("缺少发行版本标识".to_string()); + } + let emit_handle = app.clone(); + let progress_version_id = version_id.clone(); + crate::game_package_upload::upload_staged_game_package( + &client, + crate::game_package_upload::GamePackageUploadRequest { + staging_path: &resolved_path, + version_id: &version_id, + api_base_url: api_base_url.trim(), + access_token: access_token.trim(), + idempotency_key: idempotency_key.trim(), + session_identity: None, + }, + move |received_bytes, total_bytes| { + let _ = emit_handle.emit( + crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, + crate::game_package_upload::progress_event_payload( + &progress_version_id, + received_bytes, + total_bytes, + ), + ); + }, + ) + .await +} + +fn game_package_upload_staging_dir(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|app_data_root| { + crate::game_package_upload::game_package_upload_staging_dir(&app_data_root) + }) + .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) +} + +#[tauri::command] +pub(crate) fn list_local_project_export_packages( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.export_list")?; + list_local_project_export_packages_at(root) +} + +#[tauri::command] +pub(crate) fn diff_local_project_checkpoint( + project_path: String, + checkpoint_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.diff")?; + diff_local_project_checkpoint_at(root, checkpoint_id.trim()) +} + +#[tauri::command] +pub(crate) fn restore_local_project_checkpoint( + project_path: String, + checkpoint_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.restore")?; + let _lock = acquire_project_write_lock(root, "project.restore")?; + advance_agent_runtime_project_revision_locked(root)?; + restore_local_project_checkpoint_at(root, checkpoint_id.trim()) +} + +#[tauri::command] +pub(crate) fn write_project_permission_policy( + project_path: String, + policy: ProjectPermissionPolicy, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.policy_write")?; + let _lock = acquire_project_write_lock(root, "project.policy_write")?; + write_project_permission_policy_at(root, policy) +} + +const AUTOMATIC_PROJECTS_DIRECTORY_NAME: &str = "projects"; + +fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|app_data_root| { + // Automatic workspaces are AGC-managed data. Keeping them below + // the hardened per-user app-data root avoids applying the strict + // private-DACL gate to a user Documents directory whose inherited + // ACL AGC is not allowed to rewrite. + app_data_root.join(AUTOMATIC_PROJECTS_DIRECTORY_NAME) + }) + .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) +} + +/// 解析本次建项要使用的根目录:显式参数优先,其次读取 Rust 工作区偏好,最后回落 AGC 默认目录。 +pub(crate) fn resolve_game_project_creation_root( + app: &tauri::AppHandle, + requested: Option<&str>, +) -> Result { + let stored = requested + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| crate::workspace_preferences::stored_project_creation_directory(app)); + match stored.as_deref() { + Some(requested) => validate_requested_game_project_creation_root(requested), + None => automatic_local_game_projects_root(app), + } +} + +#[tauri::command] +pub(crate) async fn remove_direct_project_pending_turn( + project_path: String, + client_turn_id: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.write")?; + let thread_id = thread_id_for_project(root); + Ok(remove_pending_turn(&thread_id, client_turn_id.trim())) + }) + .await + .map_err(|error| format!("取消 DirectProject 待发消息后台任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn unsubscribe_direct_project_thread(subscription_id: String) -> Result<(), String> { + unsubscribe_thread(subscription_id.trim()); + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index edc753a2d..8a16b71a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1138,7 +1138,7 @@ impl WindowsAclRepairScope { } } -#[cfg(windows)] +#[cfg(all(windows, not(test)))] pub(crate) fn parse_windows_acl_repair_scope(value: &str) -> Result { match value.trim() { "managed" => Ok(WindowsAclRepairScope::Managed), @@ -1311,6 +1311,8 @@ fn validate_game_creator_private_path_ancestors_with_auto_elevation_scoped( ) -> Result<(), String> { #[cfg(test)] { + // 测试只校验路径,不触发宿主提权修复,因此不消费修复范围。 + let _ = scope; return validate_game_creator_private_path_ancestors(path, label); } #[cfg(not(test))] @@ -2506,7 +2508,7 @@ fn current_windows_token_user_sid_string() -> Result { /// The elevated helper may run under administrator credentials that differ /// from the original desktop user's token. Keep ownership and the private /// DACL bound to the original TokenUser SID passed by the caller. -#[cfg(windows)] +#[cfg(all(windows, not(test)))] pub(crate) fn repair_game_creator_private_acl_for_user_sid( path: &Path, target_user_sid: &str, @@ -2574,16 +2576,16 @@ fn windows_acl_repair_target(path: &Path, scope: WindowsAclRepairScope) -> PathB path.to_path_buf() } -#[cfg(windows)] +#[cfg(all(windows, not(test)))] const WINDOWS_ACL_REPAIR_AUTHORIZATION_MAX_BYTES: u64 = 4 * 1024; -#[cfg(windows)] +#[cfg(all(windows, not(test)))] struct WindowsAclRepairAuthorizationCleanup { path: PathBuf, armed: bool, } -#[cfg(windows)] +#[cfg(all(windows, not(test)))] impl WindowsAclRepairAuthorizationCleanup { fn new(path: PathBuf) -> Self { Self { path, armed: true } @@ -2594,7 +2596,7 @@ impl WindowsAclRepairAuthorizationCleanup { } } -#[cfg(windows)] +#[cfg(all(windows, not(test)))] impl Drop for WindowsAclRepairAuthorizationCleanup { fn drop(&mut self) { if self.armed { @@ -2678,7 +2680,7 @@ fn create_windows_acl_repair_authorization( Ok(nonce) } -#[cfg(windows)] +#[cfg(all(windows, not(test)))] pub(crate) fn consume_windows_acl_repair_authorization( path: &Path, target_user_sid: &str, @@ -3534,6 +3536,8 @@ fn secure_windows_game_creator_path_for_user_sid_with_owner_policy( result } +// 桌面 setup 接入;配置校验、迁移与目录安全规则仍参与测试。 +#[cfg(not(test))] pub(crate) fn configure_game_creator_runtime_config_dir( app: &tauri::AppHandle, ) -> Result<(), Box> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/context_menu.rs b/apps/ai-game-creator-shell/src-tauri/src/context_menu.rs index df6c4ccc2..19ba517cb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/context_menu.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/context_menu.rs @@ -1,7 +1,7 @@ -use tauri::{ - plugin::{Builder, TauriPlugin}, - Runtime, -}; +#[cfg(not(test))] +mod runtime; +#[cfg(not(test))] +pub(crate) use runtime::init; /// Release 客户端只取消浏览器的默认上下文菜单。 /// @@ -22,39 +22,6 @@ fn context_menu_init_script(is_release: bool) -> Option<&'static str> { is_release.then_some(RELEASE_CONTEXT_MENU_INIT_SCRIPT) } -#[cfg(all(windows, not(debug_assertions)))] -fn disable_windows_default_context_menus(webview: tauri::Webview) { - let _ = webview.with_webview(|platform_webview| { - // WebView2 的原生设置只关闭浏览器菜单,不会取消 DOM 的 - // `contextmenu` 事件,因此业务右键菜单仍然可以正常工作。 - // SAFETY: Tauri 在 WebView2 UI 线程上提供当前存活的 controller, - // 这些 COM 接口调用只使用该闭包期间有效的句柄。 - let _ = unsafe { - platform_webview - .controller() - .CoreWebView2() - .and_then(|webview| webview.Settings()) - .and_then(|settings| settings.SetAreDefaultContextMenusEnabled(false)) - }; - }); -} - -/// 注册 release-only 的默认网页上下文菜单策略。 -pub(crate) fn init() -> TauriPlugin { - let mut builder = Builder::new("release-context-menu"); - - if let Some(script) = context_menu_init_script(!cfg!(debug_assertions)) { - builder = builder.js_init_script_on_all_frames(script); - } - - #[cfg(all(windows, not(debug_assertions)))] - { - builder = builder.on_webview_ready(disable_windows_default_context_menus); - } - - builder.build() -} - #[cfg(test)] mod tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/context_menu/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/context_menu/runtime.rs new file mode 100644 index 000000000..6bb451358 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/context_menu/runtime.rs @@ -0,0 +1,39 @@ +use tauri::{ + plugin::{Builder, TauriPlugin}, + Runtime, +}; + +use super::context_menu_init_script; + +#[cfg(all(windows, not(debug_assertions)))] +fn disable_windows_default_context_menus(webview: tauri::Webview) { + let _ = webview.with_webview(|platform_webview| { + // WebView2 的原生设置只关闭浏览器菜单,不会取消 DOM 的 + // `contextmenu` 事件,因此业务右键菜单仍然可以正常工作。 + // SAFETY: Tauri 在 WebView2 UI 线程上提供当前存活的 controller, + // 这些 COM 接口调用只使用该闭包期间有效的句柄。 + let _ = unsafe { + platform_webview + .controller() + .CoreWebView2() + .and_then(|webview| webview.Settings()) + .and_then(|settings| settings.SetAreDefaultContextMenusEnabled(false)) + }; + }); +} + +/// 注册 release-only 的默认网页上下文菜单策略。 +pub(crate) fn init() -> TauriPlugin { + let mut builder = Builder::new("release-context-menu"); + + if let Some(script) = context_menu_init_script(!cfg!(debug_assertions)) { + builder = builder.js_init_script_on_all_frames(script); + } + + #[cfg(all(windows, not(debug_assertions)))] + { + builder = builder.on_webview_ready(disable_windows_default_context_menus); + } + + builder.build() +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs new file mode 100644 index 000000000..29268e088 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -0,0 +1,800 @@ +// 桌面命令注册、GUI 生命周期与独立进程启动接线。 +// 共享业务、诊断与退出决策函数留在 crate 根及所属模块供现有测试使用。 +use super::*; +use account_api::*; +use agent::design_tools::*; +use asset_generation_tasks::*; +use client_extensions::*; +use environment_check::preflight_web_game_creation; +use error_report::*; +use game_distribution_publish::*; +use plugin_host::{ + call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel, + refresh_agc_plugins, reload_agc_plugin, set_agc_plugin_enabled, set_agc_plugin_project_path, + start_agc_plugin, stop_agc_plugin, PluginHost, +}; +use shared_contracts::error_reports::ErrorReportLogInput; +use template_library::*; +use workspace_preferences::*; + +/// 更新完成后的进程重启:Windows 由 NSIS 安装程序代为重启,macOS / Linux 由客户端在安装后调用。 +#[tauri::command] +fn restart_agc_app(app: tauri::AppHandle) { + analytics::gui::mark_restart(); + app.restart(); +} + +/// 把 `shared-contracts` 的「非 canonical 资源 kind」留痕接到壳层日志上。 +/// +/// 解析边界(含 manifest 反序列化、画板 assetKind、平台 assetKind)认不出 canonical 值时, +/// 会把**原始输入串**与调用上下文交回来;这里落到 `app_log!` → AppData 日志里, +/// 以便回查非 canonical kind 的写入边界。kind 本身只接受严格值。 +/// +/// 按 `(原始串, 上下文)` 去重:manifest 读取极频繁,每次都落一行会把其它诊断刷掉; +/// 去重后"有哪些非 canonical 值、分别从哪个边界进来"仍然完整。需要具体资产身份时看 +/// `log_non_canonical_manifest_asset_kinds`(按资产去重,带 assetId / localPath)。 +fn register_non_canonical_asset_kind_reporter() { + static REPORTED: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + shared_contracts::game_creation_app::set_non_canonical_asset_kind_reporter( + |raw_kind: &str, context: &str| { + let key = format!("{context}\u{1}{raw_kind}"); + let first_seen = REPORTED + .get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new())) + .lock() + .map(|mut reported| reported.insert(key)) + .unwrap_or(true); + if !first_seen { + return; + } + app_log!( + "GameCreationApp 资源 kind 不是 canonical 值,已按严格口径收口为 unknown:rawKind={raw_kind} context={context}" + ); + }, + ); +} + +#[tauri::command] +async fn recognize_ui( + project_path: String, + state: ui_editor::state::State, +) -> Result { + ui_editor::commands::recognize_ui_impl(project_path, state).await +} + +#[tauri::command] +async fn separate_ui( + project_path: String, + asset_id: String, + state: ui_editor::state::State, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + ui_editor::commands::separate_ui_impl(project_path, asset_id, state).await +} + +#[tauri::command] +fn inspect_separation_recovery( + project_path: String, + asset_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + ui_editor::commands::separation::inspect_separation_recovery(root, &asset_id) +} + +#[tauri::command] +fn finalize_separation(project_path: String, asset_id: String) -> Result<(), String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + ui_editor::commands::separation::finalize_separation(root, &asset_id) +} + +#[tauri::command] +fn discard_separation_recovery(project_path: String, asset_id: String) -> Result<(), String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + ui_editor::commands::separation::discard_separation_recovery(root, &asset_id) +} + +#[tauri::command] +fn load_ui_design_state( + input: ui_editor::persistence::LoadUiDesignStateInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + ui_editor::persistence::load_ui_design_state_at(input) +} + +#[tauri::command] +fn save_ui_design_state( + input: ui_editor::persistence::SaveUiDesignStateInput, +) -> Result { + save_ui_design_state_with_capture(input, analytics::gui::capture_writer_context()) +} + +#[tauri::command] +fn generate_ui_design_code( + input: ui_editor::persistence::GenerateUiDesignCodeInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "file.write")?; + ui_editor::persistence::generate_ui_design_code_at(input) +} + +#[tauri::command] +fn append_application_log(level: String, source: String, message: String) -> Result<(), String> { + let config_dir = game_creator_runtime_config_dir() + .ok_or_else(|| "客户端 AppData 配置目录未初始化".to_string())?; + let level = sanitize_diagnostic_message(&level, Some(&config_dir)); + let source = sanitize_diagnostic_message(&source, Some(&config_dir)); + let message = sanitize_diagnostic_message(&message, Some(&config_dir)); + let line = format!("WEBVIEW {level} {source}: {message}"); + append_application_log_line(&line).map_err(|error| error.to_string()) +} + +#[tauri::command] +fn read_diagnostic_logs() -> Result, String> { + let Some(config_dir) = game_creator_runtime_config_dir() else { + return Ok(Vec::new()); + }; + let _guard = DIAGNOSTIC_LOG_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let directory = config_dir.join("diagnostics"); + let mut files = Vec::new(); + for name in ["application.log", "application.previous.log", "startup.log"] { + let path = directory.join(name); + let Ok(metadata) = fs::symlink_metadata(&path) else { + continue; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + continue; + } + let Ok(content) = fs::read_to_string(&path) else { + continue; + }; + files.push(ErrorReportLogInput { + name: name.to_string(), + content, + }); + } + Ok(files) +} + +/// 客户端产品名跟随构建期渠道身份:dev 渠道是「陶泥儿开发版」,release 渠道复用 +/// 正式产品名「陶泥儿」,自定义渠道带渠道后缀。非默认渠道通过 identifier 隔离 +/// 本地数据与运行身份;默认渠道结果不变。 +pub(crate) fn game_creator_product_name(app: &tauri::AppHandle) -> String { + app.package_info().name.clone() +} + +/// 配置目录就绪前的启动日志路径:优先用已经生效的配置目录(例如 `--config-dir` +/// 已经设置好的目录),否则退到平台配置根。两者都不可用时返回 `None`,此时 +/// `StartupLogSlot::fail` 仍然必须给出用户可见提示。 +fn early_startup_log_path(identifier: &str) -> Option { + let configured_dir = game_creator_runtime_config_dir(); + resolve_early_startup_log_path(configured_dir.as_deref(), identifier) +} + +impl GameCreatorGuiRunnerShutdownFailure { + fn code(self) -> &'static str { + match self { + Self::EndpointUnavailable => "endpoint_unavailable", + Self::RunnerUnresponsive => "runner_unresponsive", + Self::ProcessIdentity => "process_identity", + Self::PlatformUnsupported => "platform_unsupported", + Self::LockTimeout => "lock_timeout", + Self::Other => "other", + } + } +} + +fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { + if matches!(event, tauri::RunEvent::Exit) { + // 窗口关闭时已经按项目触发过一次快照同步;退出路径只负责在有界预算内 + // 等在途同步收尾,不重复发起(此刻窗口已销毁,重新枚举项目只会是空集)。 + wait_for_project_snapshot_syncs_on_exit(); + // 退出时统一收尾本地预览:进程内监听线程随进程消失,但 `.agent/manifest.json` + // 里的 preview 记录会留在 running 上,下次进项目就照着它渲染打不开的运行界面。 + if let Err(error) = + preview::stop_local_game_preview_on_exit(&game_creator_preview_registry()) + { + app_log!("preview.gui_exit.stop_failed: {error}"); + } + if let Err(error) = agent::shutdown_game_creator_codex_app_servers() { + app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}"); + } + } + match resolve_game_creator_gui_runner_shutdown( + event, + shutdown_external_agent_runner_for_gui_exit, + ) { + GameCreatorGuiRunnerShutdownOutcome::NotRequested => {} + GameCreatorGuiRunnerShutdownOutcome::Requested => { + app_log!("agent.runner.gui_exit.shutdown_requested") + } + GameCreatorGuiRunnerShutdownOutcome::Retained => { + app_log!("agent.runner.gui_exit.retained_for_other_windows") + } + GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => { + app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code()) + } + } +} + +fn install_agent_runtime_async_runtime_with_deep_stack() { + let runtime = match build_agent_runtime_async_runtime() { + Ok(runtime) => runtime, + Err(error) => { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + }; + tauri::async_runtime::set(runtime.handle().clone()); + // 进程生命周期内必须持有,否则 handle 立即失效。 + Box::leak(Box::new(runtime)); +} + +#[cfg(windows)] +fn run_windows_acl_repair_if_requested(args: &[String]) -> Option { + let [command, path, target_user_sid_flag, target_user_sid, authorization_flag, nonce, scope_flag, scope_value] = + args + else { + if args.first().map(String::as_str) == Some("--repair-private-acl") { + eprintln!( + "用法:--repair-private-acl <路径> --target-user-sid --authorization <票据> --scope " + ); + return Some(1); + } + return None; + }; + if command != "--repair-private-acl" + || target_user_sid_flag != "--target-user-sid" + || authorization_flag != "--authorization" + || scope_flag != "--scope" + { + eprintln!( + "用法:--repair-private-acl <路径> --target-user-sid --authorization <票据> --scope " + ); + return Some(1); + } + let path = std::path::PathBuf::from(path); + let scope = match config::parse_windows_acl_repair_scope(scope_value) { + Ok(scope) => scope, + Err(error) => { + eprintln!("{error}"); + return Some(1); + } + }; + let result = + config::consume_windows_acl_repair_authorization(&path, target_user_sid, nonce, scope) + .and_then(|()| { + config::repair_game_creator_private_acl_for_user_sid(&path, target_user_sid) + }); + match result { + Ok(()) => Some(0), + Err(error) => { + eprintln!("AGC ACL 提权修复失败:{error}"); + Some(1) + } + } +} + +pub(super) fn run() { + install_agent_runtime_async_runtime_with_deep_stack(); + register_non_canonical_asset_kind_reporter(); + let mut args = std::env::args().skip(1).collect::>(); + if let Some(exit_code) = run_direct_tools_mcp_if_requested(&args) { + std::process::exit(exit_code); + } + #[cfg(windows)] + if let Some(exit_code) = run_windows_acl_repair_if_requested(&args) { + std::process::exit(exit_code); + } + #[cfg(target_os = "linux")] + if command_sandbox_trampoline::is_trampoline_mode(&args) { + match command_sandbox_trampoline::run_trampoline() { + Ok(exit_code) => std::process::exit(exit_code), + Err(_) => std::process::exit(125), + } + } + #[cfg(target_os = "linux")] + if command_sandbox_trampoline::is_process_session_trampoline_mode(&args) { + match command_sandbox_trampoline::run_process_session_trampoline() { + Ok(exit_code) => std::process::exit(exit_code), + Err(_) => std::process::exit(125), + } + } + #[cfg(target_os = "linux")] + if is_process_session_child_mode(&args) { + match run_process_session_child(&args) { + Ok(exit_code) => std::process::exit(exit_code), + Err(_) => std::process::exit(125), + } + } + let runtime_config_dir = match take_cli_runtime_config_dir(&mut args) { + Ok(config_dir) => config_dir, + Err(error) => { + app_log!("{error}"); + std::process::exit(1); + } + }; + if args.first().map(String::as_str) == Some("--agent-runner") { + let gui_owner_required = match args.as_slice() { + [_] => false, + [_, option] if option == "--gui-owner-required" => true, + _ => { + app_log!( + "用法:--agent-runner [--gui-owner-required] --config-dir " + ); + std::process::exit(1); + } + }; + let Some(config_dir) = runtime_config_dir else { + app_log!("Agent Runner 必须显式传入 --config-dir "); + std::process::exit(1); + }; + if let Err(error) = load_platform_session_fixture_from_env(&config_dir) { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + set_game_creator_runtime_config_dir(config_dir.clone()); + if let Err(error) = run_external_agent_runner_server(config_dir, gui_owner_required) { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + return; + } + match parse_cli_command(&args) { + Ok(Some(mut command)) => { + let config_dir = + match prepare_cli_command_paths(&mut command, runtime_config_dir.as_deref()) { + Ok(config_dir) => config_dir, + Err(error) => { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + }; + if let Some(config_dir) = config_dir { + if let Err(error) = load_platform_session_fixture_from_env(&config_dir) { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + if command.requires_external_agent_runner() || command.is_read_only_status() { + let configured = if command.is_read_only_status() { + configure_external_agent_runner_read_only(&config_dir) + } else { + configure_external_agent_runner(&config_dir) + }; + if let Err(error) = configured { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + } + set_game_creator_runtime_config_dir(config_dir); + } + if command.requires_started_external_agent_runner() { + if let Err(error) = ensure_external_agent_runner_started() { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + } + if let Err(error) = run_cli_command(command) { + app_log!("agent.run.failed: {error}"); + std::process::exit(1); + } + return; + } + Ok(None) => {} + Err(error) => { + app_log!("{error}"); + std::process::exit(1); + } + } + + if let Some(config_dir) = runtime_config_dir { + set_game_creator_runtime_config_dir(config_dir); + } + + let tauri_context = tauri::generate_context!(); + // 配置目录确定之前先推导启动日志路径:优先用已经生效的配置目录(例如 + // `--config-dir`),否则退到平台配置根,保证 + // `configure_game_creator_runtime_config_dir` 自身失败也有落点。 + let startup_log = Arc::new(StartupLogSlot::new(early_startup_log_path( + tauri_context.config().identifier.as_str(), + ))); + let setup_log = Arc::clone(&startup_log); + let app = tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(context_menu::init()) + .on_page_load(|webview, payload| { + if matches!(payload.event(), tauri::webview::PageLoadEvent::Started) { + analytics::gui::page_loading(webview.window().label()); + } + }) + .on_window_event(|window, event| { + analytics::gui::window_event(window, event); + handle_project_snapshot_window_event(window, event); + }) + .manage(game_creator_preview_registry()) + .manage(ProjectResourcePreviewReadManager::default()) + .manage(PluginHost::default()) + .setup(move |app| { + error_report::initialize_notifications(app.handle()); + setup_log.append("startup.setup.begin"); + setup_log.append("startup.appdata.configure.begin"); + configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| { + // 日志路径未知也必须记录并提示:不能因为拿不到路径就静默失败。 + let config_dir = game_creator_runtime_config_dir(); + let details = + sanitize_diagnostic_message(&error.to_string(), config_dir.as_deref()); + setup_log.fail(&format!( + "startup.appdata.configure.failed details={details}" + )); + })?; + if let Some(directory) = game_creator_runtime_config_dir() { + setup_log.set(directory.join("diagnostics/startup.log")); + } + // 跨会话清扫陈旧的无头浏览器(上次异常退出/被杀留下的 ga-browser-*)。 + // 后台执行,不阻塞启动;杀树前按进程身份与可信浏览器路径双重校验。 + tauri::async_runtime::spawn(async move { + let notes = + tokio::task::spawn_blocking(crate::browser::sweep_stale_browser_processes) + .await + .unwrap_or_default(); + if !notes.is_empty() { + app_log!("startup.browser-sweep: {}", notes.join("; ")); + } + }); + setup_log.append("startup.appdata.configure.complete"); + let config_dir = game_creator_runtime_config_dir().ok_or_else(|| { + let error = std::io::Error::new( + std::io::ErrorKind::NotFound, + "客户端 AppData 配置目录未初始化", + ); + setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized"); + error + })?; + // 主窗口标题与产品名保持一致:配置里的标题来自基线配置,渠道后缀只 + // 由构建期身份决定,因此必须在这里按产品名覆盖。 + match app.get_webview_window("client") { + Some(window) => { + if let Err(error) = window.set_title(&game_creator_product_name(app.handle())) { + app_log!("startup.window-title.failed: {error}"); + } + } + None => app_log!("startup.window-title.failed: 缺少 client 主窗口"), + } + spawn_project_snapshot_scheduler(app.handle().clone()); + if let Err(error) = builtin_plugins::initialize(&config_dir) { + app_log!("startup.builtin-plugins.initialize.failed: {error}"); + } + if let Err(error) = app.state::().initialize(&config_dir) { + app_log!("startup.plugin-host.initialize.failed: {error}"); + } + if let Some(workspace) = plugin_host::resolve_plugin_workspace(app.handle()) { + if let Err(error) = app.state::().set_plugin_workspace(workspace) { + app_log!("startup.plugin-host.workspace.failed: {error}"); + } + } + if let Err(error) = editor_adapters::register_linked_editor_adapters( + app.handle(), + app.state::().inner(), + ) { + app_log!("startup.plugin-host.adapter.failed: {error}"); + } + load_platform_session_fixture_from_env(&config_dir).map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("加载平台登录态测试 fixture 失败:{error}"), + ) + })?; + setup_log.append("startup.runner.configure.begin"); + configure_external_agent_runner(&config_dir) + .inspect_err(|error| { + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.configure.failed details={details}" + )); + }) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("配置 Agent Runner 失败:{error}"), + ) + })?; + setup_log.append("startup.runner.configure.complete"); + hold_external_agent_runner_gui_participant_lock(&config_dir) + .inspect_err(|error| { + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.participant-lock.failed details={details}" + )); + }) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("建立 AGC 界面参与锁失败:{error}"), + ) + })?; + setup_log.append("startup.runner.start.begin"); + set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); + set_thread_manager_app_handle(app.handle().clone()); + platform_maintenance::set_platform_maintenance_app_handle(app.handle().clone()); + auth_session::initialize_auth_session(app.handle()); + set_asset_generation_task_app_handle(app.handle().clone()); + let manifest_event_sink = + start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; + attach_external_agent_runner_gui_owner(&manifest_event_sink) + .inspect_err(|error| { + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.attach-owner.failed details={details}" + )); + }) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("绑定 Agent Runner GUI owner 失败:{error}"), + ) + })?; + setup_log.append("startup.runner.start.complete"); + setup_log.append("startup.setup.complete"); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + analytics::gui::capture_analytics_context, + analytics::gui::record_analytics_project_open, + analytics::gui::record_analytics_project_leave, + analytics::gui::record_analytics_ui_save, + start_game_creator_external_mcp, + stop_game_creator_external_mcp, + create_automatic_local_game_project, + create_automatic_local_game_project_from_template, + init_local_game_project, + fetch_game_template_library, + get_game_template_library_access, + download_game_template, + import_local_godot_project, + import_local_cocos_project, + import_local_unity_project, + is_local_project_directory_non_empty, + inspect_local_project_directory, + pick_local_project_directory, + rename_local_game_project, + suggest_automatic_project_name, + polish_local_project_prompt, + pick_client_extension_file, + pick_client_extension_directory, + list_client_extensions, + list_agc_skill_catalog, + import_client_extension, + set_client_extension_enabled, + rename_client_extension, + remove_client_extension, + list_agc_plugins, + list_agc_extensions, + refresh_agc_plugins, + start_agc_plugin, + stop_agc_plugin, + reload_agc_plugin, + set_agc_plugin_enabled, + call_agc_plugin, + read_agc_plugin_panel, + set_agc_plugin_project_path, + open_local_project_directory, + control_agent_run, + generate_local_game_draft, + chat_with_game_creator_agent, + chat_with_game_creator_role_agent, + chat_with_game_creator_role_agent_stream, + enqueue_direct_codex_turn, + remove_direct_project_pending_turn, + preflight_web_game_creation, + cancel_direct_codex_turn, + select_game_creator_reasoning_effort, + hydrate_design_agent_session, + reset_design_agent_session, + get_design_agent_runtime_mode, + is_design_agent_debug_enabled, + set_design_agent_runtime_mode, + debug_fast_forward_design_session, + continue_design_agent_session, + decide_design_phase, + import_design_workspace_file, + list_design_workspace, + read_design_workspace_file, + start_game_creator_supervisor_runtime_task, + compact_game_creator_agent_runtime_context, + read_game_creator_agent_goal, + start_game_creator_agent_goal, + edit_game_creator_agent_goal, + pause_game_creator_agent_goal, + resume_game_creator_agent_goal, + clear_game_creator_agent_goal, + cancel_game_creator_agent_runtime_task, + retry_game_creator_agent_runtime_task, + confirm_retry_game_creator_agent_runtime_task, + confirm_game_creator_agent_runtime_task, + reject_game_creator_agent_runtime_task, + answer_game_creator_agent_runtime_user_input, + read_game_creator_agent_runtime, + read_game_creator_agent_runtimes, + resume_game_creator_agent_runtime_tasks, + confirm_resume_game_creator_agent_runtime_tasks, + schedule_game_creator_agent_ready_tasks, + check_game_creator_llm_config, + read_workspace_preferences, + set_project_creation_directory, + remember_recent_workspace, + remove_recent_workspace, + migrate_workspace_preferences, + set_chat_prompt_polish_reminder_disabled, + set_client_server_selection, + read_profile_recharge_center, + read_profile_wallet_ledger, + create_profile_recharge_order, + confirm_wechat_profile_recharge_order, + redeem_profile_reward_code, + read_client_auth_state, + refresh_client_auth_session, + send_client_phone_login_code, + login_client_with_password, + login_client_with_phone_code, + logout_client_session, + read_game_publish_availability, + suggest_game_distribution_publish_metadata, + read_game_cover_generation_price, + generate_game_distribution_cover, + publish_local_project_game, + read_game_creator_app_config, + clear_game_creator_acl_elevation_denials, + write_game_creator_app_config, + select_game_creator_model, + load_game_creator_llm_models, + discover_game_creator_llm_models, + read_editor_asset_library, + read_editor_asset_preview, + upload_platform_media_asset, + upload_local_asset, + register_local_asset, + create_ui_design_doc_from_images, + update_local_project_resource_classification, + add_local_project_resource_tags, + derive_local_project_resource, + list_pending_local_project_resource_edits, + resume_local_project_resource_edit, + request_local_project_resource_edit_service_identity_confirmation, + confirm_local_project_resource_edit_service_identity, + archive_failed_local_project_resource_edit, + normalize_local_project_raster_resource, + import_canvas_asset, + import_canvas_export, + sync_canvas_project_assets, + import_ui_editor_assets, + prepare_ui_editor_project_fonts, + read_ui_editor_font_bytes, + check_ui_editor_font_glyph_coverage, + recognize_ui, + separate_ui, + inspect_separation_recovery, + finalize_separation, + discard_separation_recovery, + load_ui_design_state, + save_ui_design_state, + generate_ui_design_code, + generate_platform_art_asset, + generate_local_project_asset, + start_local_project_asset_generation, + list_local_project_asset_generations, + open_canvas_project, + run_limited_local_command, + append_local_permission_log, + list_local_project_files, + import_local_project_image_assets, + read_local_project_file, + read_local_project_image_preview, + save_local_project_asset_file, + read_local_project_text_preview, + read_local_project_structured_preview, + read_local_project_media_preview, + cancel_local_project_resource_preview_scope, + read_local_game_memory, + read_local_agent_memory, + write_local_agent_memory, + write_local_game_memory, + list_game_creator_agent_sessions, + create_game_creator_agent_session, + fork_game_creator_agent_session, + set_active_game_creator_agent_session, + archive_game_creator_agent_session, + read_local_conversation, + read_direct_project_conversation, + list_game_creator_direct_active_turns, + subscribe_direct_project_thread, + consume_direct_project_thread, + unsubscribe_direct_project_thread, + read_direct_project_history_slice, + append_local_conversation_message, + append_direct_project_conversation_message, + build_local_project_index, + create_local_project_checkpoint, + export_local_project_package, + prepare_local_project_game_package, + upload_local_project_game_package, + list_local_project_export_packages, + diff_local_project_checkpoint, + restore_local_project_checkpoint, + read_project_permission_policy, + write_project_permission_policy, + open_game_creator_workspace_window, + open_game_creator_launcher_window, + start_local_game_preview, + activate_local_game_preview, + stop_local_game_preview, + stop_local_game_preview_if_matches, + get_local_game_preview_status, + read_local_project_resource_canvas_layout, + read_local_project_resource_graph, + update_local_project_resource_canvas_layout, + delete_local_project_asset, + read_local_project_asset_references, + rename_local_project_asset, + read_local_project_version_resource_replacement_candidates, + replace_local_project_version_resource, + get_local_game_project_revision, + get_local_game_manifest, + restart_agc_app, + append_application_log, + read_diagnostic_logs, + report_client_error, + get_pending_error_reports, + ack_error_reports, + submit_error_report, + sync_local_project_snapshot, + read_local_project_snapshot_state, + set_active_project_snapshot_workspace, + ]) + .build(tauri_context); + let app = match app { + Ok(app) => { + startup_log.append("startup.build.complete"); + app + } + Err(error) => { + if let Some(path) = startup_log.path() { + let details = sanitize_diagnostic_message(&error.to_string(), path.parent()); + startup_log.fail(&format!("startup.build.failed details={details}")); + } + app_log!("failed to build Genarrative AI Game Creator shell: {error}"); + std::process::exit(1); + } + }; + app.run(move |app_handle, event| { + if matches!(event, tauri::RunEvent::Ready) { + if let Some(directory) = game_creator_runtime_config_dir() { + analytics::gui::initialize( + app_handle.clone(), + directory, + app_handle.package_info().version.to_string(), + ); + } + } + if matches!(event, tauri::RunEvent::Exit) { + analytics::gui::exit(); + } + handle_game_creator_gui_run_event(&event); + }); +} + +#[tauri::command] +fn create_ui_design_doc_from_images( + input: ui_editor::agent_tools::CreateUiDesignDocFromImagesInput, +) -> Result { + ui_editor::agent_tools::create_ui_design_doc_from_images(input) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs index 2801d84eb..5d93f740e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs @@ -4,187 +4,17 @@ //! 目前由宿主在编译期链接(Cargo path 依赖),再按插件 manifest 的 `adapter` //! 字段注册到通用插件宿主。宿主只认适配器 id,不包含目标编辑器知识。 -#[cfg(all(windows, feature = "cocos-editor-execute"))] -use std::path::PathBuf; - -#[cfg(all(windows, feature = "cocos-editor-execute"))] -use tauri::Manager; - -use crate::plugin_host::PluginHost; -#[cfg(all( - windows, - target_arch = "x86_64", - any(feature = "unity-editor-execute", feature = "godot-editor-execute") -))] -use editor_adapter_api::{EditorAdapter, EditorConnectionInfo}; -#[cfg(any( - test, - all( - windows, - target_arch = "x86_64", - any(feature = "unity-editor-execute", feature = "godot-editor-execute") - ) -))] -use serde_json::json; -#[cfg(all( - windows, - target_arch = "x86_64", - any(feature = "unity-editor-execute", feature = "godot-editor-execute") -))] -use serde_json::Value; -use std::path::Path; - mod execution; pub(crate) use execution::*; -/// GUI 只转发已有 Runner RPC;每个引擎的连接和回执均归同一个 owner。 -#[cfg(all( - windows, - target_arch = "x86_64", - any(feature = "unity-editor-execute", feature = "godot-editor-execute") -))] -struct RunnerManagedEditorAdapter(ManagedEditor); +// 桌面注册与 Runner 资源定位属于生产启动接入。 +#[cfg(not(test))] +mod startup; +#[cfg(not(test))] +pub(crate) use startup::*; -#[cfg(all( - windows, - target_arch = "x86_64", - any(feature = "unity-editor-execute", feature = "godot-editor-execute") -))] -impl EditorAdapter for RunnerManagedEditorAdapter { - fn id(&self) -> &'static str { - self.0.adapter() - } - fn detect(&self, project_path: &Path) -> Result { - serde_json::from_value(managed_editor_rpc( - self.0, - "detect", - json!({"projectPath":project_path.to_string_lossy()}), - )?) - .map_err(|_| "编辑器探测回执格式无效".to_string()) - } - fn connect( - &mut self, - pid: u32, - project_path: &Path, - _version: &str, - ) -> Result { - serde_json::from_value(managed_editor_rpc( - self.0, - "connect", - json!({"processId":pid,"projectPath":project_path.to_string_lossy()}), - )?) - .map_err(|_| "编辑器连接回执格式无效".to_string()) - } - fn disconnect(&mut self) { - let _ = disconnect_managed_editor_connection(self.0); - } - fn translate_rpc(&self, method: &str, params: Value) -> Result { - match self.0 { - ManagedEditor::Unity => unity_editor_bridge::UnityEditorAdapter::new(Vec::new()) - .translate_rpc(method, params), - ManagedEditor::Godot => godot_editor_bridge::GodotEditorAdapter::new(Vec::new()) - .translate_rpc(method, params), - } - } - fn rpc(&self, method: &str, params: Value) -> Result { - managed_editor_rpc(self.0, method, params) - } -} - -/// 随包 payload 相对资源根目录的位置,与 `tauri.windows.conf.json` 的资源映射保持一致。 -#[allow(dead_code)] -pub(crate) const COCOS_BRIDGE_PAYLOAD_RELATIVE: &str = - "plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"; - -#[allow(dead_code)] -pub(crate) const UNITY_ATTACH_HELPER_RELATIVE: &str = - "plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe"; - -/// Runner 没有 GUI setup,仍只从随包位置或编译期工作区取受控 helper。 -pub(crate) fn configure_unity_helper_for_runtime() -> Result<(), String> { - let mut candidates = Vec::new(); - if let Ok(executable) = std::env::current_exe() { - if let Some(directory) = executable.parent() { - candidates.push(directory.join(UNITY_ATTACH_HELPER_RELATIVE)); - } - } - #[cfg(debug_assertions)] - candidates.push( - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(3) - .ok_or("插件工作区目录不可用")? - .join(UNITY_ATTACH_HELPER_RELATIVE), - ); - unity_editor_bridge::configure_helper_candidates(candidates) -} - -pub(crate) const GODOT_BRIDGE_PAYLOAD_RELATIVE: &str = - "plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll"; - -/// 安装包与开发构建使用同一插件资源布局,不将 DLL 复制进 Godot 工程。 -pub(crate) fn configure_godot_payload_for_runtime(config_dir: &Path) -> Result<(), String> { - godot_editor_bridge::configure_runtime_cache_dir(config_dir.join("godot-editor-runtime"))?; - let mut candidates = Vec::new(); - if let Ok(executable) = std::env::current_exe() { - if let Some(directory) = executable.parent() { - candidates.push(directory.join(GODOT_BRIDGE_PAYLOAD_RELATIVE)); - } - } - #[cfg(debug_assertions)] - candidates.push( - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(3) - .ok_or("插件工作区目录不可用")? - .join(GODOT_BRIDGE_PAYLOAD_RELATIVE), - ); - godot_editor_bridge::configure_payload_candidates(candidates) -} - -pub(crate) fn register_linked_editor_adapters( - app: &tauri::AppHandle, - host: &PluginHost, -) -> Result<(), String> { - #[cfg(all(windows, feature = "cocos-editor-execute"))] - { - let adapter = - cocos_editor_bridge::CocosEditorAdapter::new(cocos_bridge_payload_candidates(app)); - host.register_editor_adapter(Box::new(adapter))?; - } - #[cfg(not(all(windows, feature = "cocos-editor-execute")))] - { - let _ = (app, host); - } - #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] - { - host.register_editor_adapter(Box::new(RunnerManagedEditorAdapter(ManagedEditor::Unity)))?; - } - #[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))] - { - host.register_editor_adapter(Box::new(RunnerManagedEditorAdapter(ManagedEditor::Godot)))?; - } - Ok(()) -} - -#[cfg(all(windows, feature = "cocos-editor-execute"))] -fn cocos_bridge_payload_candidates(app: &tauri::AppHandle) -> Vec { - let mut candidates = Vec::new(); - if let Ok(resource_dir) = app.path().resource_dir() { - candidates.push(resource_dir.join(COCOS_BRIDGE_PAYLOAD_RELATIVE)); - } - // 开发构建还要能直接从 plugins/ 工作区读取尚未打包的 payload。 - #[cfg(debug_assertions)] - { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - candidates.push( - manifest_dir - .join("../../../plugins/agc-cocos-editor/native/payload") - .join("cocos-editor-bridge.dll"), - ); - } - candidates -} +#[cfg(test)] +use serde_json::json; #[cfg(test)] mod unity_receipt_tests { diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs index 00d7f7dcb..f9b87d922 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs @@ -525,11 +525,21 @@ pub(crate) fn disconnect_unity_editor_connection() { let _ = disconnect_managed_editor_connection(ManagedEditor::Unity); } -#[cfg(test)] +#[cfg(all( + test, + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" +))] pub(super) fn unity_pending_delivery() -> &'static Mutex> { pending_delivery(ManagedEditor::Unity) } -#[cfg(test)] +#[cfg(all( + test, + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" +))] pub(crate) fn unity_delivery_requires_ack(id: &str) -> bool { pending_delivery(ManagedEditor::Unity) .try_lock() diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/startup.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/startup.rs new file mode 100644 index 000000000..5732d26f3 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/startup.rs @@ -0,0 +1,181 @@ +#[cfg(all(windows, feature = "cocos-editor-execute"))] +use std::path::PathBuf; + +#[cfg(all(windows, feature = "cocos-editor-execute"))] +use tauri::Manager; + +use crate::plugin_host::PluginHost; +#[cfg(all( + windows, + target_arch = "x86_64", + any(feature = "unity-editor-execute", feature = "godot-editor-execute") +))] +use editor_adapter_api::{EditorAdapter, EditorConnectionInfo}; +#[cfg(all( + windows, + target_arch = "x86_64", + any(feature = "unity-editor-execute", feature = "godot-editor-execute") +))] +use serde_json::json; +#[cfg(all( + windows, + target_arch = "x86_64", + any(feature = "unity-editor-execute", feature = "godot-editor-execute") +))] +use serde_json::Value; +use std::path::Path; + +#[cfg(all( + windows, + target_arch = "x86_64", + any(feature = "unity-editor-execute", feature = "godot-editor-execute") +))] +use super::*; + +/// GUI 只转发已有 Runner RPC;每个引擎的连接和回执均归同一个 owner。 +#[cfg(all( + windows, + target_arch = "x86_64", + any(feature = "unity-editor-execute", feature = "godot-editor-execute") +))] +struct RunnerManagedEditorAdapter(ManagedEditor); + +#[cfg(all( + windows, + target_arch = "x86_64", + any(feature = "unity-editor-execute", feature = "godot-editor-execute") +))] +impl EditorAdapter for RunnerManagedEditorAdapter { + fn id(&self) -> &'static str { + self.0.adapter() + } + fn detect(&self, project_path: &Path) -> Result { + serde_json::from_value(managed_editor_rpc( + self.0, + "detect", + json!({"projectPath":project_path.to_string_lossy()}), + )?) + .map_err(|_| "编辑器探测回执格式无效".to_string()) + } + fn connect( + &mut self, + pid: u32, + project_path: &Path, + _version: &str, + ) -> Result { + serde_json::from_value(managed_editor_rpc( + self.0, + "connect", + json!({"processId":pid,"projectPath":project_path.to_string_lossy()}), + )?) + .map_err(|_| "编辑器连接回执格式无效".to_string()) + } + fn disconnect(&mut self) { + let _ = disconnect_managed_editor_connection(self.0); + } + fn translate_rpc(&self, method: &str, params: Value) -> Result { + match self.0 { + ManagedEditor::Unity => unity_editor_bridge::UnityEditorAdapter::new(Vec::new()) + .translate_rpc(method, params), + ManagedEditor::Godot => godot_editor_bridge::GodotEditorAdapter::new(Vec::new()) + .translate_rpc(method, params), + } + } + fn rpc(&self, method: &str, params: Value) -> Result { + managed_editor_rpc(self.0, method, params) + } +} + +/// 随包 payload 相对资源根目录的位置,与 `tauri.windows.conf.json` 的资源映射保持一致。 +#[cfg(all(windows, feature = "cocos-editor-execute"))] +const COCOS_BRIDGE_PAYLOAD_RELATIVE: &str = + "plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"; + +const UNITY_ATTACH_HELPER_RELATIVE: &str = + "plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe"; + +/// Runner 没有 GUI setup,仍只从随包位置或编译期工作区取受控 helper。 +pub(crate) fn configure_unity_helper_for_runtime() -> Result<(), String> { + let mut candidates = Vec::new(); + if let Ok(executable) = std::env::current_exe() { + if let Some(directory) = executable.parent() { + candidates.push(directory.join(UNITY_ATTACH_HELPER_RELATIVE)); + } + } + #[cfg(debug_assertions)] + candidates.push( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .ok_or("插件工作区目录不可用")? + .join(UNITY_ATTACH_HELPER_RELATIVE), + ); + unity_editor_bridge::configure_helper_candidates(candidates) +} + +pub(crate) const GODOT_BRIDGE_PAYLOAD_RELATIVE: &str = + "plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll"; + +/// 安装包与开发构建使用同一插件资源布局,不将 DLL 复制进 Godot 工程。 +pub(crate) fn configure_godot_payload_for_runtime(config_dir: &Path) -> Result<(), String> { + godot_editor_bridge::configure_runtime_cache_dir(config_dir.join("godot-editor-runtime"))?; + let mut candidates = Vec::new(); + if let Ok(executable) = std::env::current_exe() { + if let Some(directory) = executable.parent() { + candidates.push(directory.join(GODOT_BRIDGE_PAYLOAD_RELATIVE)); + } + } + #[cfg(debug_assertions)] + candidates.push( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .ok_or("插件工作区目录不可用")? + .join(GODOT_BRIDGE_PAYLOAD_RELATIVE), + ); + godot_editor_bridge::configure_payload_candidates(candidates) +} + +pub(crate) fn register_linked_editor_adapters( + app: &tauri::AppHandle, + host: &PluginHost, +) -> Result<(), String> { + #[cfg(all(windows, feature = "cocos-editor-execute"))] + { + let adapter = + cocos_editor_bridge::CocosEditorAdapter::new(cocos_bridge_payload_candidates(app)); + host.register_editor_adapter(Box::new(adapter))?; + } + #[cfg(not(all(windows, feature = "cocos-editor-execute")))] + { + let _ = (app, host); + } + #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] + { + host.register_editor_adapter(Box::new(RunnerManagedEditorAdapter(ManagedEditor::Unity)))?; + } + #[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))] + { + host.register_editor_adapter(Box::new(RunnerManagedEditorAdapter(ManagedEditor::Godot)))?; + } + Ok(()) +} + +#[cfg(all(windows, feature = "cocos-editor-execute"))] +fn cocos_bridge_payload_candidates(app: &tauri::AppHandle) -> Vec { + let mut candidates = Vec::new(); + if let Ok(resource_dir) = app.path().resource_dir() { + candidates.push(resource_dir.join(COCOS_BRIDGE_PAYLOAD_RELATIVE)); + } + // 开发构建还要能直接从 plugins/ 工作区读取尚未打包的 payload。 + #[cfg(debug_assertions)] + { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + candidates.push( + manifest_dir + .join("../../../plugins/agc-cocos-editor/native/payload") + .join("cocos-editor-bridge.dll"), + ); + } + candidates +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs index 290594405..5025dc08c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs @@ -7,9 +7,10 @@ use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; +#[cfg(not(test))] +pub(crate) use web_creation::preflight_web_game_creation; pub(crate) use web_creation::{ - host_web_creation_preflight, preflight_web_game_creation, prepare_new_web_project_at, - record_new_web_scaffold_at, + host_web_creation_preflight, prepare_new_web_project_at, record_new_web_scaffold_at, }; use serde::Deserialize; diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs index a9a395c17..42ede48ea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs @@ -6,27 +6,18 @@ use std::{ fs::{self, File}, - io::{Read, Seek, SeekFrom, Write}, + io::Write, path::{Path, PathBuf}, - time::Duration, }; -use crate::platform_session::{ - current_platform_session, validate_platform_session_identity, PlatformSessionIdentity, -}; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::Value; -pub(crate) const GAME_PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress"; - -const AGC_CLIENT_MARKER_HEADER: &str = "X-Genarrative-Client"; -const AGC_CLIENT_MARKER_VALUE: &str = "agc"; -const UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset"; -/// 服务端下发的分片大小上限;客户端只按服务端给的值发,超过它必然被拒。 -const MAX_CHUNK_BYTES: u64 = 8 * 1024 * 1024; -const CHUNK_MAX_ATTEMPTS: usize = 4; -const CHUNK_RETRY_DELAY: Duration = Duration::from_millis(500); -const UPLOAD_STAGING_DIR_NAME: &str = "game-package-staging"; +mod runtime; +pub(crate) use runtime::{ + game_package_upload_staging_dir, progress_event_payload, upload_staged_game_package, + GamePackageUploadOutcome, GamePackageUploadRequest, GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, +}; /// 已暂存(归一化后)的发行包:内容寻址,重启后同一包复用同一个文件,续传才有意义。 #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] @@ -38,36 +29,12 @@ pub(crate) struct StagedGamePackage { pub(crate) package_file_count: u32, } -#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct GamePackageUploadOutcome { - pub(crate) version_id: String, - pub(crate) status: String, - pub(crate) uploaded_bytes: u64, -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct ChunkPlan { offset: u64, length: u64, } -#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -struct PackageUploadState { - #[serde(default)] - received_bytes: u64, - #[serde(default)] - chunk_bytes: u64, - #[serde(default)] - declared_package_bytes: u64, -} - -/// 暂存目录:放在应用数据目录下,不写进项目、也不参与项目快照同步。 -pub(crate) fn game_package_upload_staging_dir(app_data_dir: &Path) -> PathBuf { - app_data_dir.join(UPLOAD_STAGING_DIR_NAME) -} - /// 把归一化后的发行包暂存到内容寻址文件,返回续传所需的元数据。 pub(crate) fn stage_game_package_bytes( staging_dir: &Path, @@ -137,15 +104,6 @@ fn next_chunk_plan(received: u64, total: u64, chunk_bytes: u64) -> Option Result, String> { - file.seek(SeekFrom::Start(plan.offset)) - .map_err(|error| format!("读取发行包失败:{error}"))?; - let mut buffer = vec![0_u8; usize::try_from(plan.length).unwrap_or(0)]; - file.read_exact(&mut buffer) - .map_err(|error| format!("读取发行包失败:{error}"))?; - Ok(buffer) -} - fn upload_url(base_url: &str, version_id: &str, suffix: &str) -> String { format!( "{}/api/game-distribution/versions/{version_id}/package{suffix}", @@ -187,288 +145,6 @@ fn parse_received_bytes(body: &str) -> Option { .and_then(Value::as_u64) } -fn platform_request( - client: &reqwest::Client, - access_token: &str, - method: reqwest::Method, - url: &str, -) -> reqwest::RequestBuilder { - client - .request(method, url) - .header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE) - .header( - reqwest::header::AUTHORIZATION, - format!("Bearer {access_token}"), - ) -} - -async fn read_upload_state( - client: &reqwest::Client, - base_url: &str, - version_id: &str, - access_token: &str, -) -> Result { - let response = platform_request( - client, - access_token, - reqwest::Method::GET, - &upload_url(base_url, version_id, "/upload-state"), - ) - .send() - .await - .map_err(|error| format!("无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"))?; - let status = response.status().as_u16(); - let body = response - .text() - .await - .map_err(|error| format!("读取上传状态失败:{error}"))?; - if status >= 400 { - let (_, message) = parse_server_error(status, &body); - return Err(message.unwrap_or_else(|| format!("读取上传状态失败(HTTP {status})"))); - } - let parsed: Value = serde_json::from_str(&body) - .map_err(|error| format!("上传状态响应不是合法 JSON:{error}"))?; - let payload = parsed.get("data").unwrap_or(&parsed); - serde_json::from_value(payload.clone()) - .map_err(|error| format!("上传状态响应缺少字段:{error}")) -} - -/// 上传一个分片;返回服务端确认后的已收字节。 -async fn upload_chunk( - client: &reqwest::Client, - base_url: &str, - version_id: &str, - access_token: &str, - idempotency_key: &str, - plan: ChunkPlan, - body: Vec, -) -> Result { - let response = platform_request( - client, - access_token, - reqwest::Method::PUT, - &upload_url(base_url, version_id, "/chunk"), - ) - .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") - .header(UPLOAD_OFFSET_HEADER, plan.offset.to_string()) - .header("Idempotency-Key", format!("{idempotency_key}:chunk")) - .body(body) - .send() - .await - .map_err(|error| ChunkUploadError::Retryable(format!("发行包分片发送失败:{error}")))?; - let status = response.status().as_u16(); - let text = response - .text() - .await - .map_err(|error| ChunkUploadError::Retryable(format!("读取分片响应失败:{error}")))?; - if status == 409 { - let (code, message) = parse_server_error(status, &text); - if code.as_deref() == Some("PACKAGE_UPLOAD_OFFSET_MISMATCH") { - let received = parse_received_bytes(&text).ok_or_else(|| { - ChunkUploadError::Fatal("分片偏移不一致,但服务端未返回权威偏移".to_string()) - })?; - return Ok(received); - } - return Err(ChunkUploadError::Fatal( - message.unwrap_or_else(|| "发行包分片被拒绝".to_string()), - )); - } - if status >= 500 || status == 408 || status == 429 { - let (_, message) = parse_server_error(status, &text); - return Err(ChunkUploadError::Retryable( - message.unwrap_or_else(|| format!("发行包分片上传失败(HTTP {status})")), - )); - } - if status >= 400 { - let (_, message) = parse_server_error(status, &text); - return Err(ChunkUploadError::Fatal( - message.unwrap_or_else(|| format!("发行包分片被拒绝(HTTP {status})")), - )); - } - let parsed: Value = serde_json::from_str(&text) - .map_err(|error| ChunkUploadError::Retryable(format!("分片响应不是合法 JSON:{error}")))?; - let payload = parsed.get("data").unwrap_or(&parsed); - payload - .get("receivedBytes") - .and_then(Value::as_u64) - .ok_or_else(|| ChunkUploadError::Fatal("分片响应缺少 receivedBytes".to_string())) -} - -enum ChunkUploadError { - Retryable(String), - Fatal(String), -} - -async fn complete_upload( - client: &reqwest::Client, - base_url: &str, - version_id: &str, - access_token: &str, - idempotency_key: &str, -) -> Result { - let response = platform_request( - client, - access_token, - reqwest::Method::POST, - &upload_url(base_url, version_id, "/complete"), - ) - .header("Idempotency-Key", format!("{idempotency_key}:complete")) - .send() - .await - .map_err(|error| format!("完成发行包上传失败:{error}"))?; - let status = response.status().as_u16(); - let text = response - .text() - .await - .map_err(|error| format!("读取完成响应失败:{error}"))?; - if status >= 400 { - let (_, message) = parse_server_error(status, &text); - return Err(message.unwrap_or_else(|| format!("完成发行包上传失败(HTTP {status})"))); - } - let parsed: Value = - serde_json::from_str(&text).map_err(|error| format!("完成响应不是合法 JSON:{error}"))?; - let payload = parsed.get("data").unwrap_or(&parsed); - Ok(GamePackageUploadOutcome { - version_id: payload - .get("versionId") - .and_then(Value::as_str) - .unwrap_or(version_id) - .to_string(), - status: payload - .get("status") - .and_then(Value::as_str) - .unwrap_or("uploaded") - .to_string(), - uploaded_bytes: 0, - }) -} - -pub(crate) struct GamePackageUploadRequest<'a> { - pub(crate) staging_path: &'a Path, - pub(crate) version_id: &'a str, - pub(crate) api_base_url: &'a str, - pub(crate) access_token: &'a str, - pub(crate) idempotency_key: &'a str, - pub(crate) session_identity: Option<&'a PlatformSessionIdentity>, -} - -impl<'a> GamePackageUploadRequest<'a> { - fn current_access_token(&self) -> Result { - let Some(expected) = self.session_identity else { - return Ok(self.access_token.to_string()); - }; - let session = current_platform_session().ok_or_else(|| { - "authentication-required: 陶泥儿登录态缺失,请重新登录后重试".to_string() - })?; - validate_platform_session_identity(expected)?; - Ok(session.access_token) - } -} - -/// 分片续传主循环:权威偏移来自服务端,失败按可重试分类退避,偏移不符立即按权威偏移继续。 -pub(crate) async fn upload_staged_game_package( - client: &reqwest::Client, - request: GamePackageUploadRequest<'_>, - mut on_progress: impl FnMut(u64, u64), -) -> Result { - let total_bytes = fs::metadata(request.staging_path) - .map_err(|error| format!("读取发行包暂存文件失败:{error}"))? - .len(); - if total_bytes == 0 { - return Err("发行包暂存文件为空,请重新导出试玩包".to_string()); - } - let access_token = request.current_access_token()?; - let state = read_upload_state( - client, - request.api_base_url, - request.version_id, - &access_token, - ) - .await?; - if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes { - return Err(format!( - "本地发行包与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布", - total_bytes, state.declared_package_bytes - )); - } - let chunk_bytes = if state.chunk_bytes == 0 { - MAX_CHUNK_BYTES - } else { - state.chunk_bytes.min(MAX_CHUNK_BYTES) - }; - let mut received = state.received_bytes.min(total_bytes); - on_progress(received, total_bytes); - let mut file = File::open(request.staging_path) - .map_err(|error| format!("打开发行包暂存文件失败:{error}"))?; - while let Some(plan) = next_chunk_plan(received, total_bytes, chunk_bytes) { - let body = read_chunk(&mut file, plan)?; - let mut attempt = 1_usize; - loop { - let access_token = request.current_access_token()?; - match upload_chunk( - client, - request.api_base_url, - request.version_id, - &access_token, - request.idempotency_key, - plan, - body.clone(), - ) - .await - { - Ok(next_received) => { - received = next_received.min(total_bytes); - on_progress(received, total_bytes); - break; - } - Err(ChunkUploadError::Fatal(error)) => return Err(error), - Err(ChunkUploadError::Retryable(error)) => { - if attempt >= CHUNK_MAX_ATTEMPTS { - return Err(format!("{error}(已尝试 {attempt} 次,可重新发布续传)")); - } - attempt += 1; - tokio::time::sleep(CHUNK_RETRY_DELAY).await; - } - } - } - // 权威偏移可能在重试期间前进(例如响应丢失后服务端已写入),按服务端口径对齐。 - let access_token = request.current_access_token()?; - let authoritative = read_upload_state( - client, - request.api_base_url, - request.version_id, - &access_token, - ) - .await?; - received = authoritative.received_bytes.min(total_bytes); - on_progress(received, total_bytes); - } - let access_token = request.current_access_token()?; - let mut outcome = complete_upload( - client, - request.api_base_url, - request.version_id, - &access_token, - request.idempotency_key, - ) - .await?; - outcome.uploaded_bytes = total_bytes; - Ok(outcome) -} - -/// 进度事件的载荷形状(渲染进程按它显示进度)。 -pub(crate) fn progress_event_payload( - version_id: &str, - received_bytes: u64, - total_bytes: u64, -) -> Value { - json!({ - "versionId": version_id, - "receivedBytes": received_bytes, - "totalBytes": total_bytes, - }) -} - #[cfg(test)] mod tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs new file mode 100644 index 000000000..259757a5e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs @@ -0,0 +1,335 @@ +use super::*; +use crate::platform_session::{ + current_platform_session, validate_platform_session_identity, PlatformSessionIdentity, +}; +use serde_json::json; +use std::{ + io::{Read, Seek, SeekFrom}, + time::Duration, +}; + +pub(crate) const GAME_PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress"; + +const AGC_CLIENT_MARKER_HEADER: &str = "X-Genarrative-Client"; +const AGC_CLIENT_MARKER_VALUE: &str = "agc"; +const UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset"; +/// 服务端下发的分片大小上限;客户端只按服务端给的值发,超过它必然被拒。 +const MAX_CHUNK_BYTES: u64 = 8 * 1024 * 1024; +const CHUNK_MAX_ATTEMPTS: usize = 4; +const CHUNK_RETRY_DELAY: Duration = Duration::from_millis(500); +const UPLOAD_STAGING_DIR_NAME: &str = "game-package-staging"; + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GamePackageUploadOutcome { + pub(crate) version_id: String, + pub(crate) status: String, + pub(crate) uploaded_bytes: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct PackageUploadState { + #[serde(default)] + received_bytes: u64, + #[serde(default)] + chunk_bytes: u64, + #[serde(default)] + declared_package_bytes: u64, +} + +/// 暂存目录:放在应用数据目录下,不写进项目、也不参与项目快照同步。 +pub(crate) fn game_package_upload_staging_dir(app_data_dir: &Path) -> PathBuf { + app_data_dir.join(UPLOAD_STAGING_DIR_NAME) +} + +fn read_chunk(file: &mut File, plan: ChunkPlan) -> Result, String> { + file.seek(SeekFrom::Start(plan.offset)) + .map_err(|error| format!("读取发行包失败:{error}"))?; + let mut buffer = vec![0_u8; usize::try_from(plan.length).unwrap_or(0)]; + file.read_exact(&mut buffer) + .map_err(|error| format!("读取发行包失败:{error}"))?; + Ok(buffer) +} + +fn platform_request( + client: &reqwest::Client, + access_token: &str, + method: reqwest::Method, + url: &str, +) -> reqwest::RequestBuilder { + client + .request(method, url) + .header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {access_token}"), + ) +} + +async fn read_upload_state( + client: &reqwest::Client, + base_url: &str, + version_id: &str, + access_token: &str, +) -> Result { + let response = platform_request( + client, + access_token, + reqwest::Method::GET, + &upload_url(base_url, version_id, "/upload-state"), + ) + .send() + .await + .map_err(|error| format!("无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"))?; + let status = response.status().as_u16(); + let body = response + .text() + .await + .map_err(|error| format!("读取上传状态失败:{error}"))?; + if status >= 400 { + let (_, message) = parse_server_error(status, &body); + return Err(message.unwrap_or_else(|| format!("读取上传状态失败(HTTP {status})"))); + } + let parsed: Value = serde_json::from_str(&body) + .map_err(|error| format!("上传状态响应不是合法 JSON:{error}"))?; + let payload = parsed.get("data").unwrap_or(&parsed); + serde_json::from_value(payload.clone()) + .map_err(|error| format!("上传状态响应缺少字段:{error}")) +} + +/// 上传一个分片;返回服务端确认后的已收字节。 +async fn upload_chunk( + client: &reqwest::Client, + base_url: &str, + version_id: &str, + access_token: &str, + idempotency_key: &str, + plan: ChunkPlan, + body: Vec, +) -> Result { + let response = platform_request( + client, + access_token, + reqwest::Method::PUT, + &upload_url(base_url, version_id, "/chunk"), + ) + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") + .header(UPLOAD_OFFSET_HEADER, plan.offset.to_string()) + .header("Idempotency-Key", format!("{idempotency_key}:chunk")) + .body(body) + .send() + .await + .map_err(|error| ChunkUploadError::Retryable(format!("发行包分片发送失败:{error}")))?; + let status = response.status().as_u16(); + let text = response + .text() + .await + .map_err(|error| ChunkUploadError::Retryable(format!("读取分片响应失败:{error}")))?; + if status == 409 { + let (code, message) = parse_server_error(status, &text); + if code.as_deref() == Some("PACKAGE_UPLOAD_OFFSET_MISMATCH") { + let received = parse_received_bytes(&text).ok_or_else(|| { + ChunkUploadError::Fatal("分片偏移不一致,但服务端未返回权威偏移".to_string()) + })?; + return Ok(received); + } + return Err(ChunkUploadError::Fatal( + message.unwrap_or_else(|| "发行包分片被拒绝".to_string()), + )); + } + if status >= 500 || status == 408 || status == 429 { + let (_, message) = parse_server_error(status, &text); + return Err(ChunkUploadError::Retryable( + message.unwrap_or_else(|| format!("发行包分片上传失败(HTTP {status})")), + )); + } + if status >= 400 { + let (_, message) = parse_server_error(status, &text); + return Err(ChunkUploadError::Fatal( + message.unwrap_or_else(|| format!("发行包分片被拒绝(HTTP {status})")), + )); + } + let parsed: Value = serde_json::from_str(&text) + .map_err(|error| ChunkUploadError::Retryable(format!("分片响应不是合法 JSON:{error}")))?; + let payload = parsed.get("data").unwrap_or(&parsed); + payload + .get("receivedBytes") + .and_then(Value::as_u64) + .ok_or_else(|| ChunkUploadError::Fatal("分片响应缺少 receivedBytes".to_string())) +} + +enum ChunkUploadError { + Retryable(String), + Fatal(String), +} + +async fn complete_upload( + client: &reqwest::Client, + base_url: &str, + version_id: &str, + access_token: &str, + idempotency_key: &str, +) -> Result { + let response = platform_request( + client, + access_token, + reqwest::Method::POST, + &upload_url(base_url, version_id, "/complete"), + ) + .header("Idempotency-Key", format!("{idempotency_key}:complete")) + .send() + .await + .map_err(|error| format!("完成发行包上传失败:{error}"))?; + let status = response.status().as_u16(); + let text = response + .text() + .await + .map_err(|error| format!("读取完成响应失败:{error}"))?; + if status >= 400 { + let (_, message) = parse_server_error(status, &text); + return Err(message.unwrap_or_else(|| format!("完成发行包上传失败(HTTP {status})"))); + } + let parsed: Value = + serde_json::from_str(&text).map_err(|error| format!("完成响应不是合法 JSON:{error}"))?; + let payload = parsed.get("data").unwrap_or(&parsed); + Ok(GamePackageUploadOutcome { + version_id: payload + .get("versionId") + .and_then(Value::as_str) + .unwrap_or(version_id) + .to_string(), + status: payload + .get("status") + .and_then(Value::as_str) + .unwrap_or("uploaded") + .to_string(), + uploaded_bytes: 0, + }) +} + +pub(crate) struct GamePackageUploadRequest<'a> { + pub(crate) staging_path: &'a Path, + pub(crate) version_id: &'a str, + pub(crate) api_base_url: &'a str, + pub(crate) access_token: &'a str, + pub(crate) idempotency_key: &'a str, + pub(crate) session_identity: Option<&'a PlatformSessionIdentity>, +} + +impl<'a> GamePackageUploadRequest<'a> { + fn current_access_token(&self) -> Result { + let Some(expected) = self.session_identity else { + return Ok(self.access_token.to_string()); + }; + let session = current_platform_session().ok_or_else(|| { + "authentication-required: 陶泥儿登录态缺失,请重新登录后重试".to_string() + })?; + validate_platform_session_identity(expected)?; + Ok(session.access_token) + } +} + +/// 分片续传主循环:权威偏移来自服务端,失败按可重试分类退避,偏移不符立即按权威偏移继续。 +pub(crate) async fn upload_staged_game_package( + client: &reqwest::Client, + request: GamePackageUploadRequest<'_>, + mut on_progress: impl FnMut(u64, u64), +) -> Result { + let total_bytes = fs::metadata(request.staging_path) + .map_err(|error| format!("读取发行包暂存文件失败:{error}"))? + .len(); + if total_bytes == 0 { + return Err("发行包暂存文件为空,请重新导出试玩包".to_string()); + } + let access_token = request.current_access_token()?; + let state = read_upload_state( + client, + request.api_base_url, + request.version_id, + &access_token, + ) + .await?; + if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes { + return Err(format!( + "本地发行包与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布", + total_bytes, state.declared_package_bytes + )); + } + let chunk_bytes = if state.chunk_bytes == 0 { + MAX_CHUNK_BYTES + } else { + state.chunk_bytes.min(MAX_CHUNK_BYTES) + }; + let mut received = state.received_bytes.min(total_bytes); + on_progress(received, total_bytes); + let mut file = File::open(request.staging_path) + .map_err(|error| format!("打开发行包暂存文件失败:{error}"))?; + while let Some(plan) = next_chunk_plan(received, total_bytes, chunk_bytes) { + let body = read_chunk(&mut file, plan)?; + let mut attempt = 1_usize; + loop { + let access_token = request.current_access_token()?; + match upload_chunk( + client, + request.api_base_url, + request.version_id, + &access_token, + request.idempotency_key, + plan, + body.clone(), + ) + .await + { + Ok(next_received) => { + received = next_received.min(total_bytes); + on_progress(received, total_bytes); + break; + } + Err(ChunkUploadError::Fatal(error)) => return Err(error), + Err(ChunkUploadError::Retryable(error)) => { + if attempt >= CHUNK_MAX_ATTEMPTS { + return Err(format!("{error}(已尝试 {attempt} 次,可重新发布续传)")); + } + attempt += 1; + tokio::time::sleep(CHUNK_RETRY_DELAY).await; + } + } + } + // 权威偏移可能在重试期间前进(例如响应丢失后服务端已写入),按服务端口径对齐。 + let access_token = request.current_access_token()?; + let authoritative = read_upload_state( + client, + request.api_base_url, + request.version_id, + &access_token, + ) + .await?; + received = authoritative.received_bytes.min(total_bytes); + on_progress(received, total_bytes); + } + let access_token = request.current_access_token()?; + let mut outcome = complete_upload( + client, + request.api_base_url, + request.version_id, + &access_token, + request.idempotency_key, + ) + .await?; + outcome.uploaded_bytes = total_bytes; + Ok(outcome) +} + +/// 进度事件的载荷形状(渲染进程按它显示进度)。 +pub(crate) fn progress_event_payload( + version_id: &str, + received_bytes: u64, + total_bytes: u64, +) -> Value { + json!({ + "versionId": version_id, + "receivedBytes": received_bytes, + "totalBytes": total_bytes, + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 52a019481..ea1b8dc56 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -4,6 +4,11 @@ #[path = "../build_support/godot_bundle.rs"] mod godot_bundle; +// 复用随包资源声明的既有单测(校验通过/拒绝用例),生产运行时只经 codex_bundle 使用布局。 +#[cfg(test)] +#[path = "../build_support/package_layout.rs"] +mod package_layout; + use std::collections::BTreeMap; use std::fs; use std::fs::{File, OpenOptions}; @@ -29,7 +34,6 @@ use platform_llm::{ use reqwest::header; use serde::{Deserialize, Serialize}; use sha2::Digest; -use shared_contracts::error_reports::ErrorReportLogInput; use shared_contracts::game_creation_app::{ game_creation_app_asset_category_for_kind, game_creation_app_asset_category_from_str, game_creation_app_asset_effective_category, new_game_creation_app_manifest, @@ -50,17 +54,9 @@ use shared_contracts::game_creation_app::{ }; // `Emitter` 同时被 `use super::*` 的子模块依赖(通知、Agent 事件等都从 crate 根取该 trait), // 不要因为根模块自身不再直接 `.emit(..)` 就删掉它。 -use environment_check::preflight_web_game_creation; -use tauri::{Emitter, Manager}; -use tauri_plugin_dialog::DialogExt; -use tauri_plugin_opener::OpenerExt; - -/// 更新完成后的进程重启:Windows 由 NSIS 安装程序代为重启,macOS / Linux 由客户端在安装后调用。 -#[tauri::command] -fn restart_agc_app(app: tauri::AppHandle) { - analytics::gui::mark_restart(); - app.restart(); -} +use tauri::Emitter; +#[cfg(not(test))] +use tauri::Manager; /// Rust 侧普通文本日志:保留 stderr 输出,同时将同一行持久化到 AppData。 /// 诊断包只在用户主动提交时读取这些 raw log;结构化错误事件仍只留在内存。 @@ -72,36 +68,6 @@ macro_rules! app_log { }}; } -/// 把 `shared-contracts` 的「非 canonical 资源 kind」留痕接到壳层日志上。 -/// -/// 解析边界(含 manifest 反序列化、画板 assetKind、平台 assetKind)认不出 canonical 值时, -/// 会把**原始输入串**与调用上下文交回来;这里落到 `app_log!` → AppData 日志里, -/// 以便回查非 canonical kind 的写入边界。kind 本身只接受严格值。 -/// -/// 按 `(原始串, 上下文)` 去重:manifest 读取极频繁,每次都落一行会把其它诊断刷掉; -/// 去重后"有哪些非 canonical 值、分别从哪个边界进来"仍然完整。需要具体资产身份时看 -/// `log_non_canonical_manifest_asset_kinds`(按资产去重,带 assetId / localPath)。 -fn register_non_canonical_asset_kind_reporter() { - static REPORTED: std::sync::OnceLock>> = - std::sync::OnceLock::new(); - shared_contracts::game_creation_app::set_non_canonical_asset_kind_reporter( - |raw_kind: &str, context: &str| { - let key = format!("{context}\u{1}{raw_kind}"); - let first_seen = REPORTED - .get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new())) - .lock() - .map(|mut reported| reported.insert(key)) - .unwrap_or(true); - if !first_seen { - return; - } - app_log!( - "GameCreationApp 资源 kind 不是 canonical 值,已按严格口径收口为 unknown:rawKind={raw_kind} context={context}" - ); - }, - ); -} - // 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。 // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs")); @@ -130,6 +96,11 @@ mod context_menu; #[cfg(all(debug_assertions, not(test)))] mod debug; mod delegation; +// 桌面与独立进程启动接线不参与单元测试;共享实现仍在各自模块编译。 +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::game_creator_product_name; mod editor_adapter; mod editor_adapters; mod environment_check; @@ -164,16 +135,12 @@ mod user_input; mod windows; mod workspace_preferences; -use account_api::*; -use agent::design_tools::*; use agent::*; use agent_native_tools::*; -use asset_generation_tasks::*; use assets::*; use auth_session::*; use browser::*; use cli::*; -use client_extensions::*; use collaboration::*; use command_exec::*; use command_output::*; @@ -182,8 +149,6 @@ use commands::*; use config::*; use context_compaction::*; use delegation::*; -use error_report::*; -use game_distribution_publish::*; use git_inspect::*; use goal::*; use image_inspect::*; @@ -192,82 +157,17 @@ use llm_catalog::load_game_creator_llm_models; use patchset::*; use platform_asset_upload::upload_platform_media_asset; use platform_session::*; -use plugin_host::{ - call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel, - refresh_agc_plugins, reload_agc_plugin, set_agc_plugin_enabled, set_agc_plugin_project_path, - start_agc_plugin, stop_agc_plugin, PluginHost, -}; use preview::*; use process_session::*; use project::*; +#[cfg(not(test))] use project_snapshot::*; use repository_context::*; use resource_inspect::*; use resource_preview_scheduler::*; use runner::*; -use template_library::*; use user_input::*; use windows::*; -use workspace_preferences::*; -#[tauri::command] -async fn recognize_ui( - project_path: String, - state: ui_editor::state::State, -) -> Result { - ui_editor::commands::recognize_ui_impl(project_path, state).await -} - -#[tauri::command] -async fn separate_ui( - project_path: String, - asset_id: String, - state: ui_editor::state::State, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - ui_editor::commands::separate_ui_impl(project_path, asset_id, state).await -} - -#[tauri::command] -fn inspect_separation_recovery( - project_path: String, - asset_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.list")?; - ui_editor::commands::separation::inspect_separation_recovery(root, &asset_id) -} - -#[tauri::command] -fn finalize_separation(project_path: String, asset_id: String) -> Result<(), String> { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - ui_editor::commands::separation::finalize_separation(root, &asset_id) -} - -#[tauri::command] -fn discard_separation_recovery(project_path: String, asset_id: String) -> Result<(), String> { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - ui_editor::commands::separation::discard_separation_recovery(root, &asset_id) -} - -#[tauri::command] -fn load_ui_design_state( - input: ui_editor::persistence::LoadUiDesignStateInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.list")?; - ui_editor::persistence::load_ui_design_state_at(input) -} - -#[tauri::command] -fn save_ui_design_state( - input: ui_editor::persistence::SaveUiDesignStateInput, -) -> Result { - save_ui_design_state_with_capture(input, analytics::gui::capture_writer_context()) -} - fn save_ui_design_state_with_capture( input: ui_editor::persistence::SaveUiDesignStateInput, capture: Option<( @@ -299,22 +199,6 @@ fn save_ui_design_state_with_capture( Ok(result) } -#[tauri::command] -fn generate_ui_design_code( - input: ui_editor::persistence::GenerateUiDesignCodeInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "file.write")?; - ui_editor::persistence::generate_ui_design_code_at(input) -} - -#[tauri::command] -fn create_ui_design_doc_from_images( - input: ui_editor::agent_tools::CreateUiDesignDocFromImagesInput, -) -> Result { - ui_editor::agent_tools::create_ui_design_doc_from_images(input) -} - #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct InitLocalProjectResult { @@ -383,23 +267,6 @@ struct GameCreatorChatAgentReply { reply_text: String, } -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct GameCreatorRoleAgentChatStreamEvent { - project_path: String, - agent_id: String, - run_id: String, - status: String, - delta_text: String, - accumulated_text: String, - finish_reason: Option, - session_id: Option, - runtime_status: Option, - runtime_phase: Option, - runtime_summary: Option, - runtime_state: Option, -} - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct AgentRuntimeState { @@ -888,29 +755,6 @@ struct GameCreatorAgentProgressEvent { message: String, } -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct GameCreatorDirectTurnUpdateEvent { - project_path: String, - turn_id: String, - sequence: u64, - status: String, - activity: Option, - accumulated_text: Option, - /// 本回合内发生变化的结构化工具调用集合(只有变化时才带,老事件没有这个字段)。 - /// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。 - #[serde(skip_serializing_if = "Option::is_none")] - tool_calls: Option>, - /// 本回合当前累计的思考过程(流式整段替换);拿不到时字段缺席。 - #[serde(skip_serializing_if = "Option::is_none")] - reasoning_text: Option, - /// 本回合**顺序真相**里本次发生变化的那几条(文本段 / 工具位置标记)。 - /// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。 - #[serde(skip_serializing_if = "Option::is_none")] - stream_items: Option>, - updated_at: u64, -} - #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorLlmConfigStatus { @@ -1173,12 +1017,6 @@ struct GeneratedPlatformArtAsset { slice_warning: Option, } -#[derive(Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct OpenCanvasProjectResult { - url: String, -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CanvasExportMetadata { @@ -1715,47 +1553,6 @@ const DIAGNOSTIC_LOG_MAX_BYTES: u64 = 256 * 1024; static DIAGNOSTIC_LOG_LOCK: OnceLock> = OnceLock::new(); static STARTUP_ERROR_DIALOG_SHOWN: AtomicBool = AtomicBool::new(false); -#[tauri::command] -fn append_application_log(level: String, source: String, message: String) -> Result<(), String> { - let config_dir = game_creator_runtime_config_dir() - .ok_or_else(|| "客户端 AppData 配置目录未初始化".to_string())?; - let level = sanitize_diagnostic_message(&level, Some(&config_dir)); - let source = sanitize_diagnostic_message(&source, Some(&config_dir)); - let message = sanitize_diagnostic_message(&message, Some(&config_dir)); - let line = format!("WEBVIEW {level} {source}: {message}"); - append_application_log_line(&line).map_err(|error| error.to_string()) -} - -#[tauri::command] -fn read_diagnostic_logs() -> Result, String> { - let Some(config_dir) = game_creator_runtime_config_dir() else { - return Ok(Vec::new()); - }; - let _guard = DIAGNOSTIC_LOG_LOCK - .get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let directory = config_dir.join("diagnostics"); - let mut files = Vec::new(); - for name in ["application.log", "application.previous.log", "startup.log"] { - let path = directory.join(name); - let Ok(metadata) = fs::symlink_metadata(&path) else { - continue; - }; - if metadata.file_type().is_symlink() || !metadata.is_file() { - continue; - } - let Ok(content) = fs::read_to_string(&path) else { - continue; - }; - files.push(ErrorReportLogInput { - name: name.to_string(), - content, - }); - } - Ok(files) -} - fn diagnostic_timestamp() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -2013,21 +1810,6 @@ fn show_startup_error_dialog(log_path: Option<&Path>) { } } -/// 客户端产品名跟随构建期渠道身份:dev 渠道是「陶泥儿开发版」,release 渠道复用 -/// 正式产品名「陶泥儿」,自定义渠道带渠道后缀。非默认渠道通过 identifier 隔离 -/// 本地数据与运行身份;默认渠道结果不变。 -pub(crate) fn game_creator_product_name(app: &tauri::AppHandle) -> String { - app.package_info().name.clone() -} - -/// 配置目录就绪前的启动日志路径:优先用已经生效的配置目录(例如 `--config-dir` -/// 已经设置好的目录),否则退到平台配置根。两者都不可用时返回 `None`,此时 -/// `StartupLogSlot::fail` 仍然必须给出用户可见提示。 -fn early_startup_log_path(identifier: &str) -> Option { - let configured_dir = game_creator_runtime_config_dir(); - resolve_early_startup_log_path(configured_dir.as_deref(), identifier) -} - fn resolve_early_startup_log_path( configured_dir: Option<&Path>, identifier: &str, @@ -2128,19 +1910,6 @@ enum GameCreatorGuiRunnerShutdownFailure { Other, } -impl GameCreatorGuiRunnerShutdownFailure { - fn code(self) -> &'static str { - match self { - Self::EndpointUnavailable => "endpoint_unavailable", - Self::RunnerUnresponsive => "runner_unresponsive", - Self::ProcessIdentity => "process_identity", - Self::PlatformUnsupported => "platform_unsupported", - Self::LockTimeout => "lock_timeout", - Self::Other => "other", - } - } -} - fn classify_game_creator_gui_runner_shutdown_error( error: &str, ) -> GameCreatorGuiRunnerShutdownFailure { @@ -2183,39 +1952,6 @@ where } } -fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { - if matches!(event, tauri::RunEvent::Exit) { - // 窗口关闭时已经按项目触发过一次快照同步;退出路径只负责在有界预算内 - // 等在途同步收尾,不重复发起(此刻窗口已销毁,重新枚举项目只会是空集)。 - wait_for_project_snapshot_syncs_on_exit(); - // 退出时统一收尾本地预览:进程内监听线程随进程消失,但 `.agent/manifest.json` - // 里的 preview 记录会留在 running 上,下次进项目就照着它渲染打不开的运行界面。 - if let Err(error) = - preview::stop_local_game_preview_on_exit(&game_creator_preview_registry()) - { - app_log!("preview.gui_exit.stop_failed: {error}"); - } - if let Err(error) = agent::shutdown_game_creator_codex_app_servers() { - app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}"); - } - } - match resolve_game_creator_gui_runner_shutdown( - event, - shutdown_external_agent_runner_for_gui_exit, - ) { - GameCreatorGuiRunnerShutdownOutcome::NotRequested => {} - GameCreatorGuiRunnerShutdownOutcome::Requested => { - app_log!("agent.runner.gui_exit.shutdown_requested") - } - GameCreatorGuiRunnerShutdownOutcome::Retained => { - app_log!("agent.runner.gui_exit.retained_for_other_windows") - } - GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => { - app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code()) - } - } -} - /// Tauri 的全局异步 runtime 默认由 `TokioRuntime::new()` 建出来,worker 线程吃 /// tokio 默认栈。Runtime 的 agent turn 调用链深到本仓库另一处专门给自己的后台 /// 线程配了 AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES;但凡经 async_runtime::spawn @@ -2233,65 +1969,6 @@ fn build_agent_runtime_async_runtime() -> Result runtime, - Err(error) => { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - }; - tauri::async_runtime::set(runtime.handle().clone()); - // 进程生命周期内必须持有,否则 handle 立即失效。 - Box::leak(Box::new(runtime)); -} - -#[cfg(windows)] -fn run_windows_acl_repair_if_requested(args: &[String]) -> Option { - let [command, path, target_user_sid_flag, target_user_sid, authorization_flag, nonce, scope_flag, scope_value] = - args - else { - if args.first().map(String::as_str) == Some("--repair-private-acl") { - eprintln!( - "用法:--repair-private-acl <路径> --target-user-sid --authorization <票据> --scope " - ); - return Some(1); - } - return None; - }; - if command != "--repair-private-acl" - || target_user_sid_flag != "--target-user-sid" - || authorization_flag != "--authorization" - || scope_flag != "--scope" - { - eprintln!( - "用法:--repair-private-acl <路径> --target-user-sid --authorization <票据> --scope " - ); - return Some(1); - } - let path = std::path::PathBuf::from(path); - let scope = match config::parse_windows_acl_repair_scope(scope_value) { - Ok(scope) => scope, - Err(error) => { - eprintln!("{error}"); - return Some(1); - } - }; - let result = - config::consume_windows_acl_repair_authorization(&path, target_user_sid, nonce, scope) - .and_then(|()| { - config::repair_game_creator_private_acl_for_user_sid(&path, target_user_sid) - }); - match result { - Ok(()) => Some(0), - Err(error) => { - eprintln!("AGC ACL 提权修复失败:{error}"); - Some(1) - } - } -} - #[cfg(test)] mod async_runtime_stack_tests { /// 每帧固定占 16 KiB,用 black_box 挡住优化,让递归深度直接换算成栈用量。 @@ -2324,513 +2001,7 @@ mod async_runtime_stack_tests { #[cfg(not(test))] fn main() { - install_agent_runtime_async_runtime_with_deep_stack(); - register_non_canonical_asset_kind_reporter(); - let mut args = std::env::args().skip(1).collect::>(); - if let Some(exit_code) = run_direct_tools_mcp_if_requested(&args) { - std::process::exit(exit_code); - } - #[cfg(windows)] - if let Some(exit_code) = run_windows_acl_repair_if_requested(&args) { - std::process::exit(exit_code); - } - #[cfg(target_os = "linux")] - if command_sandbox_trampoline::is_trampoline_mode(&args) { - match command_sandbox_trampoline::run_trampoline() { - Ok(exit_code) => std::process::exit(exit_code), - Err(_) => std::process::exit(125), - } - } - #[cfg(target_os = "linux")] - if command_sandbox_trampoline::is_process_session_trampoline_mode(&args) { - match command_sandbox_trampoline::run_process_session_trampoline() { - Ok(exit_code) => std::process::exit(exit_code), - Err(_) => std::process::exit(125), - } - } - #[cfg(target_os = "linux")] - if is_process_session_child_mode(&args) { - match run_process_session_child(&args) { - Ok(exit_code) => std::process::exit(exit_code), - Err(_) => std::process::exit(125), - } - } - let runtime_config_dir = match take_cli_runtime_config_dir(&mut args) { - Ok(config_dir) => config_dir, - Err(error) => { - app_log!("{error}"); - std::process::exit(1); - } - }; - if args.first().map(String::as_str) == Some("--agent-runner") { - let gui_owner_required = match args.as_slice() { - [_] => false, - [_, option] if option == "--gui-owner-required" => true, - _ => { - app_log!( - "用法:--agent-runner [--gui-owner-required] --config-dir " - ); - std::process::exit(1); - } - }; - let Some(config_dir) = runtime_config_dir else { - app_log!("Agent Runner 必须显式传入 --config-dir "); - std::process::exit(1); - }; - if let Err(error) = load_platform_session_fixture_from_env(&config_dir) { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - set_game_creator_runtime_config_dir(config_dir.clone()); - if let Err(error) = run_external_agent_runner_server(config_dir, gui_owner_required) { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - return; - } - match parse_cli_command(&args) { - Ok(Some(mut command)) => { - let config_dir = - match prepare_cli_command_paths(&mut command, runtime_config_dir.as_deref()) { - Ok(config_dir) => config_dir, - Err(error) => { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - }; - if let Some(config_dir) = config_dir { - if let Err(error) = load_platform_session_fixture_from_env(&config_dir) { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - if command.requires_external_agent_runner() || command.is_read_only_status() { - let configured = if command.is_read_only_status() { - configure_external_agent_runner_read_only(&config_dir) - } else { - configure_external_agent_runner(&config_dir) - }; - if let Err(error) = configured { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - } - set_game_creator_runtime_config_dir(config_dir); - } - if command.requires_started_external_agent_runner() { - if let Err(error) = ensure_external_agent_runner_started() { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - } - if let Err(error) = run_cli_command(command) { - app_log!("agent.run.failed: {error}"); - std::process::exit(1); - } - return; - } - Ok(None) => {} - Err(error) => { - app_log!("{error}"); - std::process::exit(1); - } - } - - if let Some(config_dir) = runtime_config_dir { - set_game_creator_runtime_config_dir(config_dir); - } - - let tauri_context = tauri::generate_context!(); - // 配置目录确定之前先推导启动日志路径:优先用已经生效的配置目录(例如 - // `--config-dir`),否则退到平台配置根,保证 - // `configure_game_creator_runtime_config_dir` 自身失败也有落点。 - let startup_log = Arc::new(StartupLogSlot::new(early_startup_log_path( - tauri_context.config().identifier.as_str(), - ))); - let setup_log = Arc::clone(&startup_log); - let app = tauri::Builder::default() - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_clipboard_manager::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(context_menu::init()) - .on_page_load(|webview, payload| { - if matches!(payload.event(), tauri::webview::PageLoadEvent::Started) { - analytics::gui::page_loading(webview.window().label()); - } - }) - .on_window_event(|window, event| { - analytics::gui::window_event(window, event); - handle_project_snapshot_window_event(window, event); - }) - .manage(game_creator_preview_registry()) - .manage(ProjectResourcePreviewReadManager::default()) - .manage(PluginHost::default()) - .setup(move |app| { - error_report::initialize_notifications(app.handle()); - setup_log.append("startup.setup.begin"); - setup_log.append("startup.appdata.configure.begin"); - configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| { - // 日志路径未知也必须记录并提示:不能因为拿不到路径就静默失败。 - let config_dir = game_creator_runtime_config_dir(); - let details = - sanitize_diagnostic_message(&error.to_string(), config_dir.as_deref()); - setup_log.fail(&format!( - "startup.appdata.configure.failed details={details}" - )); - })?; - if let Some(directory) = game_creator_runtime_config_dir() { - setup_log.set(directory.join("diagnostics/startup.log")); - } - // 跨会话清扫陈旧的无头浏览器(上次异常退出/被杀留下的 ga-browser-*)。 - // 后台执行,不阻塞启动;杀树前按进程身份与可信浏览器路径双重校验。 - tauri::async_runtime::spawn(async move { - let notes = - tokio::task::spawn_blocking(crate::browser::sweep_stale_browser_processes) - .await - .unwrap_or_default(); - if !notes.is_empty() { - app_log!("startup.browser-sweep: {}", notes.join("; ")); - } - }); - setup_log.append("startup.appdata.configure.complete"); - let config_dir = game_creator_runtime_config_dir().ok_or_else(|| { - let error = std::io::Error::new( - std::io::ErrorKind::NotFound, - "客户端 AppData 配置目录未初始化", - ); - setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized"); - error - })?; - // 主窗口标题与产品名保持一致:配置里的标题来自基线配置,渠道后缀只 - // 由构建期身份决定,因此必须在这里按产品名覆盖。 - match app.get_webview_window("client") { - Some(window) => { - if let Err(error) = window.set_title(&game_creator_product_name(app.handle())) { - app_log!("startup.window-title.failed: {error}"); - } - } - None => app_log!("startup.window-title.failed: 缺少 client 主窗口"), - } - spawn_project_snapshot_scheduler(app.handle().clone()); - if let Err(error) = builtin_plugins::initialize(&config_dir) { - app_log!("startup.builtin-plugins.initialize.failed: {error}"); - } - if let Err(error) = app.state::().initialize(&config_dir) { - app_log!("startup.plugin-host.initialize.failed: {error}"); - } - if let Some(workspace) = plugin_host::resolve_plugin_workspace(app.handle()) { - if let Err(error) = app.state::().set_plugin_workspace(workspace) { - app_log!("startup.plugin-host.workspace.failed: {error}"); - } - } - if let Err(error) = editor_adapters::register_linked_editor_adapters( - app.handle(), - app.state::().inner(), - ) { - app_log!("startup.plugin-host.adapter.failed: {error}"); - } - load_platform_session_fixture_from_env(&config_dir).map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - format!("加载平台登录态测试 fixture 失败:{error}"), - ) - })?; - setup_log.append("startup.runner.configure.begin"); - configure_external_agent_runner(&config_dir) - .inspect_err(|error| { - let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); - setup_log.fail(&format!( - "startup.runner.configure.failed details={details}" - )); - }) - .map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("配置 Agent Runner 失败:{error}"), - ) - })?; - setup_log.append("startup.runner.configure.complete"); - hold_external_agent_runner_gui_participant_lock(&config_dir) - .inspect_err(|error| { - let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); - setup_log.fail(&format!( - "startup.runner.participant-lock.failed details={details}" - )); - }) - .map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - format!("建立 AGC 界面参与锁失败:{error}"), - ) - })?; - setup_log.append("startup.runner.start.begin"); - set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); - set_thread_manager_app_handle(app.handle().clone()); - platform_maintenance::set_platform_maintenance_app_handle(app.handle().clone()); - auth_session::initialize_auth_session(app.handle()); - set_asset_generation_task_app_handle(app.handle().clone()); - let manifest_event_sink = - start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; - attach_external_agent_runner_gui_owner(&manifest_event_sink) - .inspect_err(|error| { - let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); - setup_log.fail(&format!( - "startup.runner.attach-owner.failed details={details}" - )); - }) - .map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("绑定 Agent Runner GUI owner 失败:{error}"), - ) - })?; - setup_log.append("startup.runner.start.complete"); - setup_log.append("startup.setup.complete"); - Ok(()) - }) - .invoke_handler(tauri::generate_handler![ - analytics::gui::capture_analytics_context, - analytics::gui::record_analytics_project_open, - analytics::gui::record_analytics_project_leave, - analytics::gui::record_analytics_ui_save, - start_game_creator_external_mcp, - stop_game_creator_external_mcp, - create_automatic_local_game_project, - create_automatic_local_game_project_from_template, - init_local_game_project, - fetch_game_template_library, - get_game_template_library_access, - download_game_template, - import_local_godot_project, - import_local_cocos_project, - import_local_unity_project, - is_local_project_directory_non_empty, - inspect_local_project_directory, - pick_local_project_directory, - rename_local_game_project, - suggest_automatic_project_name, - polish_local_project_prompt, - pick_client_extension_file, - pick_client_extension_directory, - list_client_extensions, - list_agc_skill_catalog, - import_client_extension, - set_client_extension_enabled, - rename_client_extension, - remove_client_extension, - list_agc_plugins, - list_agc_extensions, - refresh_agc_plugins, - start_agc_plugin, - stop_agc_plugin, - reload_agc_plugin, - set_agc_plugin_enabled, - call_agc_plugin, - read_agc_plugin_panel, - set_agc_plugin_project_path, - open_local_project_directory, - control_agent_run, - generate_local_game_draft, - chat_with_game_creator_agent, - chat_with_game_creator_role_agent, - chat_with_game_creator_role_agent_stream, - enqueue_direct_codex_turn, - remove_direct_project_pending_turn, - preflight_web_game_creation, - cancel_direct_codex_turn, - select_game_creator_reasoning_effort, - hydrate_design_agent_session, - reset_design_agent_session, - get_design_agent_runtime_mode, - is_design_agent_debug_enabled, - set_design_agent_runtime_mode, - debug_fast_forward_design_session, - continue_design_agent_session, - decide_design_phase, - import_design_workspace_file, - list_design_workspace, - read_design_workspace_file, - start_game_creator_supervisor_runtime_task, - compact_game_creator_agent_runtime_context, - read_game_creator_agent_goal, - start_game_creator_agent_goal, - edit_game_creator_agent_goal, - pause_game_creator_agent_goal, - resume_game_creator_agent_goal, - clear_game_creator_agent_goal, - cancel_game_creator_agent_runtime_task, - retry_game_creator_agent_runtime_task, - confirm_retry_game_creator_agent_runtime_task, - confirm_game_creator_agent_runtime_task, - reject_game_creator_agent_runtime_task, - answer_game_creator_agent_runtime_user_input, - read_game_creator_agent_runtime, - read_game_creator_agent_runtimes, - resume_game_creator_agent_runtime_tasks, - confirm_resume_game_creator_agent_runtime_tasks, - schedule_game_creator_agent_ready_tasks, - check_game_creator_llm_config, - read_workspace_preferences, - set_project_creation_directory, - remember_recent_workspace, - remove_recent_workspace, - migrate_workspace_preferences, - set_chat_prompt_polish_reminder_disabled, - set_client_server_selection, - read_profile_recharge_center, - read_profile_wallet_ledger, - create_profile_recharge_order, - confirm_wechat_profile_recharge_order, - redeem_profile_reward_code, - read_client_auth_state, - refresh_client_auth_session, - send_client_phone_login_code, - login_client_with_password, - login_client_with_phone_code, - logout_client_session, - read_game_publish_availability, - suggest_game_distribution_publish_metadata, - read_game_cover_generation_price, - generate_game_distribution_cover, - publish_local_project_game, - read_game_creator_app_config, - clear_game_creator_acl_elevation_denials, - write_game_creator_app_config, - select_game_creator_model, - load_game_creator_llm_models, - discover_game_creator_llm_models, - read_editor_asset_library, - read_editor_asset_preview, - upload_platform_media_asset, - upload_local_asset, - register_local_asset, - create_ui_design_doc_from_images, - update_local_project_resource_classification, - add_local_project_resource_tags, - derive_local_project_resource, - list_pending_local_project_resource_edits, - resume_local_project_resource_edit, - request_local_project_resource_edit_service_identity_confirmation, - confirm_local_project_resource_edit_service_identity, - archive_failed_local_project_resource_edit, - normalize_local_project_raster_resource, - import_canvas_asset, - import_canvas_export, - sync_canvas_project_assets, - import_ui_editor_assets, - prepare_ui_editor_project_fonts, - read_ui_editor_font_bytes, - check_ui_editor_font_glyph_coverage, - recognize_ui, - separate_ui, - inspect_separation_recovery, - finalize_separation, - discard_separation_recovery, - load_ui_design_state, - save_ui_design_state, - generate_ui_design_code, - generate_platform_art_asset, - generate_local_project_asset, - start_local_project_asset_generation, - list_local_project_asset_generations, - open_canvas_project, - run_limited_local_command, - append_local_permission_log, - list_local_project_files, - import_local_project_image_assets, - read_local_project_file, - read_local_project_image_preview, - save_local_project_asset_file, - read_local_project_text_preview, - read_local_project_structured_preview, - read_local_project_media_preview, - cancel_local_project_resource_preview_scope, - read_local_game_memory, - read_local_agent_memory, - write_local_agent_memory, - write_local_game_memory, - list_game_creator_agent_sessions, - create_game_creator_agent_session, - fork_game_creator_agent_session, - set_active_game_creator_agent_session, - archive_game_creator_agent_session, - read_local_conversation, - read_direct_project_conversation, - list_game_creator_direct_active_turns, - subscribe_direct_project_thread, - consume_direct_project_thread, - unsubscribe_direct_project_thread, - read_direct_project_history_slice, - append_local_conversation_message, - append_direct_project_conversation_message, - build_local_project_index, - create_local_project_checkpoint, - export_local_project_package, - prepare_local_project_game_package, - upload_local_project_game_package, - list_local_project_export_packages, - diff_local_project_checkpoint, - restore_local_project_checkpoint, - read_project_permission_policy, - write_project_permission_policy, - open_game_creator_workspace_window, - open_game_creator_launcher_window, - start_local_game_preview, - activate_local_game_preview, - stop_local_game_preview, - stop_local_game_preview_if_matches, - get_local_game_preview_status, - read_local_project_resource_canvas_layout, - read_local_project_resource_graph, - update_local_project_resource_canvas_layout, - delete_local_project_asset, - read_local_project_asset_references, - rename_local_project_asset, - read_local_project_version_resource_replacement_candidates, - replace_local_project_version_resource, - get_local_game_project_revision, - get_local_game_manifest, - restart_agc_app, - append_application_log, - read_diagnostic_logs, - report_client_error, - get_pending_error_reports, - ack_error_reports, - submit_error_report, - sync_local_project_snapshot, - read_local_project_snapshot_state, - set_active_project_snapshot_workspace, - ]) - .build(tauri_context); - let app = match app { - Ok(app) => { - startup_log.append("startup.build.complete"); - app - } - Err(error) => { - if let Some(path) = startup_log.path() { - let details = sanitize_diagnostic_message(&error.to_string(), path.parent()); - startup_log.fail(&format!("startup.build.failed details={details}")); - } - app_log!("failed to build Genarrative AI Game Creator shell: {error}"); - std::process::exit(1); - } - }; - app.run(move |app_handle, event| { - if matches!(event, tauri::RunEvent::Ready) { - if let Some(directory) = game_creator_runtime_config_dir() { - analytics::gui::initialize( - app_handle.clone(), - directory, - app_handle.package_info().version.to_string(), - ); - } - } - if matches!(event, tauri::RunEvent::Exit) { - analytics::gui::exit(); - } - handle_game_creator_gui_run_event(&event); - }); + desktop::run(); } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index 0f4b03cd4..3e7ed9d46 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -1,15 +1,9 @@ -use serde::Deserialize; -use std::fs::{self, OpenOptions}; -use std::io::Read; -use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; +#[cfg(not(test))] +mod startup; +#[cfg(not(test))] +pub(crate) use startup::load_platform_session_fixture_from_env; -/// Debug-only fixture hook used by the deterministic AGC E2E. The hook takes -/// a path, rather than credentials on argv, so a child Runner can inherit the -/// test identity without putting the bearer token in process listings. -pub(crate) const PLATFORM_SESSION_FIXTURE_ENV: &str = "GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE"; -const PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION: &str = "genarrative-agc-platform-session-fixture.v1"; -const PLATFORM_SESSION_FIXTURE_MAX_BYTES: u64 = 16 * 1024; +use std::sync::{Mutex, OnceLock}; /// 平台会话快照 = 身份(登录主体 + 服务 origin)+ 凭据(当前 access token)。 /// @@ -69,217 +63,6 @@ pub(crate) fn editor_api_mode() -> EditorApiMode { editor_api_mode_for_build(cfg!(debug_assertions), cfg!(debug_assertions)) } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct PlatformSessionFixture { - schema_version: String, - user_id: String, - access_token: String, - api_base_url: String, - generation: u64, -} - -fn metadata_is_link_or_reparse(metadata: &fs::Metadata) -> bool { - if metadata.file_type().is_symlink() { - return true; - } - #[cfg(windows)] - { - use std::os::windows::fs::MetadataExt; - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; - if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { - return true; - } - } - false -} - -fn path_component_is_inside(root: &Path, candidate: &Path) -> bool { - #[cfg(windows)] - { - // Windows paths are case-insensitive. Comparing normalized UTF-16 - // strings would introduce an unnecessary lossy conversion; component - // comparison with ASCII-folding covers the drive/normal path forms - // used by the fixture while retaining separators as boundaries. - let root = root.components().collect::>(); - let candidate = candidate.components().collect::>(); - return candidate.len() > root.len() - && root.iter().zip(candidate.iter()).all(|(left, right)| { - left.as_os_str() - .to_string_lossy() - .eq_ignore_ascii_case(&right.as_os_str().to_string_lossy()) - }); - } - #[cfg(not(windows))] - { - candidate.starts_with(root) && candidate != root - } -} - -fn validate_fixture_path(config_dir: &Path, fixture_path: &Path) -> Result { - if !config_dir.is_absolute() { - return Err("平台登录态 fixture 所在 AppData 必须是绝对路径".to_string()); - } - if !fixture_path.is_absolute() { - return Err("平台登录态 fixture 路径必须是绝对路径".to_string()); - } - - // Check the directory entry before canonicalization so a symlink/junction - // cannot be silently followed into an unrelated credential location. - let config_entry = fs::symlink_metadata(config_dir) - .map_err(|_| "平台登录态 fixture AppData 不可读取".to_string())?; - if metadata_is_link_or_reparse(&config_entry) || !config_entry.is_dir() { - return Err("平台登录态 fixture AppData 必须是普通目录".to_string()); - } - let canonical_config = fs::canonicalize(config_dir) - .map_err(|_| "平台登录态 fixture AppData 不可解析".to_string())?; - - let fixture_entry = fs::symlink_metadata(fixture_path) - .map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; - if metadata_is_link_or_reparse(&fixture_entry) || !fixture_entry.is_file() { - return Err("平台登录态 fixture 必须是普通文件".to_string()); - } - if fixture_entry.len() > PLATFORM_SESSION_FIXTURE_MAX_BYTES { - return Err("平台登录态 fixture 过大,已拒绝读取".to_string()); - } - let canonical_fixture = fs::canonicalize(fixture_path) - .map_err(|_| "平台登录态 fixture 文件不可解析".to_string())?; - if !path_component_is_inside(&canonical_config, &canonical_fixture) { - return Err("平台登录态 fixture 必须位于 --config-dir 内".to_string()); - } - - // Walk the path below AppData and reject links/reparse points in every - // ancestor as well as at the leaf. This keeps the check useful on - // platforms where canonicalize otherwise follows a junction. - let relative = canonical_fixture - .strip_prefix(&canonical_config) - .map_err(|_| "平台登录态 fixture 必须位于 --config-dir 内".to_string())?; - let mut current = canonical_config; - for component in relative.components() { - current.push(component.as_os_str()); - let metadata = fs::symlink_metadata(¤t) - .map_err(|_| "平台登录态 fixture 路径不可读取".to_string())?; - if metadata_is_link_or_reparse(&metadata) { - return Err("平台登录态 fixture 路径不能包含链接或 reparse point".to_string()); - } - if current != canonical_fixture && !metadata.is_dir() { - return Err("平台登录态 fixture 父路径必须是普通目录".to_string()); - } - } - Ok(canonical_fixture) -} - -fn read_fixture_file(path: &Path) -> Result, String> { - let before = - fs::symlink_metadata(path).map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; - if metadata_is_link_or_reparse(&before) || !before.is_file() { - return Err("平台登录态 fixture 必须是普通文件".to_string()); - } - if before.len() > PLATFORM_SESSION_FIXTURE_MAX_BYTES { - return Err("平台登录态 fixture 过大,已拒绝读取".to_string()); - } - - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW); - } - let file = options - .open(path) - .map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; - let opened = file - .metadata() - .map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; - if metadata_is_link_or_reparse(&opened) || !opened.is_file() || opened.len() > before.len() { - return Err("平台登录态 fixture 文件身份校验失败".to_string()); - } - let mut bytes = - Vec::with_capacity(opened.len().min(PLATFORM_SESSION_FIXTURE_MAX_BYTES) as usize); - file.take(PLATFORM_SESSION_FIXTURE_MAX_BYTES + 1) - .read_to_end(&mut bytes) - .map_err(|_| "读取平台登录态 fixture 失败".to_string())?; - if bytes.len() as u64 > PLATFORM_SESSION_FIXTURE_MAX_BYTES { - return Err("平台登录态 fixture 过大,已拒绝读取".to_string()); - } - Ok(bytes) -} - -fn parse_platform_session_fixture(bytes: &[u8]) -> Result { - let fixture = serde_json::from_slice::(bytes) - .map_err(|_| "平台登录态 fixture 格式无效".to_string())?; - if fixture.schema_version != PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION { - return Err("平台登录态 fixture 版本不受支持".to_string()); - } - if fixture.generation == 0 { - return Err("平台登录态 fixture generation 无效".to_string()); - } - if fixture - .user_id - .chars() - .any(|character| character.is_control()) - { - return Err("平台登录态 fixture 用户身份无效".to_string()); - } - if fixture - .access_token - .chars() - .any(|character| character.is_control()) - { - return Err("平台登录态 fixture 凭据无效".to_string()); - } - Ok(fixture) -} - -/// Loads a deliberately narrow, file-backed account fixture for Debug E2E -/// processes. Release binaries fail closed if the hook is present. The -/// fixture path must be a regular file below the process' explicit -/// `--config-dir`; credentials are never accepted on argv or emitted in an -/// error string. -pub(crate) fn load_platform_session_fixture_from_env(config_dir: &Path) -> Result<(), String> { - load_platform_session_fixture_from_env_for_build(config_dir, cfg!(debug_assertions)) -} - -pub(crate) fn load_platform_session_fixture_from_env_for_build( - config_dir: &Path, - debug_build: bool, -) -> Result<(), String> { - let Some(raw_path) = std::env::var_os(PLATFORM_SESSION_FIXTURE_ENV) else { - return Ok(()); - }; - if !debug_build { - return Err("当前发行版拒绝使用平台登录态测试 fixture".to_string()); - } - let raw_path = raw_path - .to_str() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "平台登录态 fixture 路径无效".to_string())?; - let fixture_path = validate_fixture_path(config_dir, Path::new(raw_path))?; - let bytes = read_fixture_file(&fixture_path)?; - let fixture = parse_platform_session_fixture(&bytes)?; - // fixture 的 generation 同时充当身份代次与写入 revision:一个 fixture 只表达 - // “从零安装一次确定的会话”,不表达同一身份的凭据续期。 - let snapshot = validated_platform_session_snapshot( - &fixture.user_id, - &fixture.access_token, - &fixture.api_base_url, - fixture.generation, - fixture.generation, - )?; - let mut current = platform_session() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - // A fresh CLI/Runner normally starts at revision zero. Replacing the - // state here also makes a Debug GUI fixture deterministic without relaxing - // the normal account-switch rules. - current.revision = snapshot.revision; - current.identity_generation = snapshot.identity_generation; - current.snapshot = Some(snapshot); - Ok(()) -} - #[derive(Default)] struct PlatformSessionState { revision: u64, @@ -318,6 +101,7 @@ pub(crate) fn analytics_identity_snapshot() -> Option<(crate::analytics::contrac // 仅供后台初始化使用:持锁发布 GUI 身份,避免快照与通知之间出现空窗。 // 回调只允许更新内存或非阻塞投递,不能访问磁盘或重新取得认证锁。 +#[cfg(not(test))] pub(crate) fn initialize_analytics_identity( initialize: impl FnOnce(crate::analytics::contract::Route, u64), ) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session/startup.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session/startup.rs new file mode 100644 index 000000000..770506cc9 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session/startup.rs @@ -0,0 +1,224 @@ +// 桌面与 CLI 启动的 Debug E2E 会话接入;发行版拒绝逻辑与文件校验保持一致。 +use super::{platform_session, validated_platform_session_snapshot}; +use serde::Deserialize; +use std::fs::{self, OpenOptions}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +/// Debug-only fixture hook used by the deterministic AGC E2E. The hook takes +/// a path, rather than credentials on argv, so a child Runner can inherit the +/// test identity without putting the bearer token in process listings. +pub(crate) const PLATFORM_SESSION_FIXTURE_ENV: &str = "GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE"; +const PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION: &str = "genarrative-agc-platform-session-fixture.v1"; +const PLATFORM_SESSION_FIXTURE_MAX_BYTES: u64 = 16 * 1024; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PlatformSessionFixture { + schema_version: String, + user_id: String, + access_token: String, + api_base_url: String, + generation: u64, +} + +fn metadata_is_link_or_reparse(metadata: &fs::Metadata) -> bool { + if metadata.file_type().is_symlink() { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return true; + } + } + false +} + +fn path_component_is_inside(root: &Path, candidate: &Path) -> bool { + #[cfg(windows)] + { + // Windows paths are case-insensitive. Comparing normalized UTF-16 + // strings would introduce an unnecessary lossy conversion; component + // comparison with ASCII-folding covers the drive/normal path forms + // used by the fixture while retaining separators as boundaries. + let root = root.components().collect::>(); + let candidate = candidate.components().collect::>(); + return candidate.len() > root.len() + && root.iter().zip(candidate.iter()).all(|(left, right)| { + left.as_os_str() + .to_string_lossy() + .eq_ignore_ascii_case(&right.as_os_str().to_string_lossy()) + }); + } + #[cfg(not(windows))] + { + candidate.starts_with(root) && candidate != root + } +} + +fn validate_fixture_path(config_dir: &Path, fixture_path: &Path) -> Result { + if !config_dir.is_absolute() { + return Err("平台登录态 fixture 所在 AppData 必须是绝对路径".to_string()); + } + if !fixture_path.is_absolute() { + return Err("平台登录态 fixture 路径必须是绝对路径".to_string()); + } + + // Check the directory entry before canonicalization so a symlink/junction + // cannot be silently followed into an unrelated credential location. + let config_entry = fs::symlink_metadata(config_dir) + .map_err(|_| "平台登录态 fixture AppData 不可读取".to_string())?; + if metadata_is_link_or_reparse(&config_entry) || !config_entry.is_dir() { + return Err("平台登录态 fixture AppData 必须是普通目录".to_string()); + } + let canonical_config = fs::canonicalize(config_dir) + .map_err(|_| "平台登录态 fixture AppData 不可解析".to_string())?; + + let fixture_entry = fs::symlink_metadata(fixture_path) + .map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; + if metadata_is_link_or_reparse(&fixture_entry) || !fixture_entry.is_file() { + return Err("平台登录态 fixture 必须是普通文件".to_string()); + } + if fixture_entry.len() > PLATFORM_SESSION_FIXTURE_MAX_BYTES { + return Err("平台登录态 fixture 过大,已拒绝读取".to_string()); + } + let canonical_fixture = fs::canonicalize(fixture_path) + .map_err(|_| "平台登录态 fixture 文件不可解析".to_string())?; + if !path_component_is_inside(&canonical_config, &canonical_fixture) { + return Err("平台登录态 fixture 必须位于 --config-dir 内".to_string()); + } + + // Walk the path below AppData and reject links/reparse points in every + // ancestor as well as at the leaf. This keeps the check useful on + // platforms where canonicalize otherwise follows a junction. + let relative = canonical_fixture + .strip_prefix(&canonical_config) + .map_err(|_| "平台登录态 fixture 必须位于 --config-dir 内".to_string())?; + let mut current = canonical_config; + for component in relative.components() { + current.push(component.as_os_str()); + let metadata = fs::symlink_metadata(¤t) + .map_err(|_| "平台登录态 fixture 路径不可读取".to_string())?; + if metadata_is_link_or_reparse(&metadata) { + return Err("平台登录态 fixture 路径不能包含链接或 reparse point".to_string()); + } + if current != canonical_fixture && !metadata.is_dir() { + return Err("平台登录态 fixture 父路径必须是普通目录".to_string()); + } + } + Ok(canonical_fixture) +} + +fn read_fixture_file(path: &Path) -> Result, String> { + let before = + fs::symlink_metadata(path).map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; + if metadata_is_link_or_reparse(&before) || !before.is_file() { + return Err("平台登录态 fixture 必须是普通文件".to_string()); + } + if before.len() > PLATFORM_SESSION_FIXTURE_MAX_BYTES { + return Err("平台登录态 fixture 过大,已拒绝读取".to_string()); + } + + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let file = options + .open(path) + .map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; + let opened = file + .metadata() + .map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; + if metadata_is_link_or_reparse(&opened) || !opened.is_file() || opened.len() > before.len() { + return Err("平台登录态 fixture 文件身份校验失败".to_string()); + } + let mut bytes = + Vec::with_capacity(opened.len().min(PLATFORM_SESSION_FIXTURE_MAX_BYTES) as usize); + file.take(PLATFORM_SESSION_FIXTURE_MAX_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| "读取平台登录态 fixture 失败".to_string())?; + if bytes.len() as u64 > PLATFORM_SESSION_FIXTURE_MAX_BYTES { + return Err("平台登录态 fixture 过大,已拒绝读取".to_string()); + } + Ok(bytes) +} + +fn parse_platform_session_fixture(bytes: &[u8]) -> Result { + let fixture = serde_json::from_slice::(bytes) + .map_err(|_| "平台登录态 fixture 格式无效".to_string())?; + if fixture.schema_version != PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION { + return Err("平台登录态 fixture 版本不受支持".to_string()); + } + if fixture.generation == 0 { + return Err("平台登录态 fixture generation 无效".to_string()); + } + if fixture + .user_id + .chars() + .any(|character| character.is_control()) + { + return Err("平台登录态 fixture 用户身份无效".to_string()); + } + if fixture + .access_token + .chars() + .any(|character| character.is_control()) + { + return Err("平台登录态 fixture 凭据无效".to_string()); + } + Ok(fixture) +} + +/// Loads a deliberately narrow, file-backed account fixture for Debug E2E +/// processes. Release binaries fail closed if the hook is present. The +/// fixture path must be a regular file below the process' explicit +/// `--config-dir`; credentials are never accepted on argv or emitted in an +/// error string. +pub(crate) fn load_platform_session_fixture_from_env(config_dir: &Path) -> Result<(), String> { + load_platform_session_fixture_from_env_for_build(config_dir, cfg!(debug_assertions)) +} + +pub(crate) fn load_platform_session_fixture_from_env_for_build( + config_dir: &Path, + debug_build: bool, +) -> Result<(), String> { + let Some(raw_path) = std::env::var_os(PLATFORM_SESSION_FIXTURE_ENV) else { + return Ok(()); + }; + if !debug_build { + return Err("当前发行版拒绝使用平台登录态测试 fixture".to_string()); + } + let raw_path = raw_path + .to_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "平台登录态 fixture 路径无效".to_string())?; + let fixture_path = validate_fixture_path(config_dir, Path::new(raw_path))?; + let bytes = read_fixture_file(&fixture_path)?; + let fixture = parse_platform_session_fixture(&bytes)?; + // fixture 的 generation 同时充当身份代次与写入 revision:一个 fixture 只表达 + // “从零安装一次确定的会话”,不表达同一身份的凭据续期。 + let snapshot = validated_platform_session_snapshot( + &fixture.user_id, + &fixture.access_token, + &fixture.api_base_url, + fixture.generation, + fixture.generation, + )?; + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + // A fresh CLI/Runner normally starts at revision zero. Replacing the + // state here also makes a Debug GUI fixture deterministic without relaxing + // the normal account-switch rules. + current.revision = snapshot.revision; + current.identity_generation = snapshot.identity_generation; + current.snapshot = Some(snapshot); + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs index 48a354445..7694d53f3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -19,10 +19,14 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use tauri::{Emitter, Manager, State}; use crate::editor_adapter::EditorAdapter; +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::*; + type EditorRegistry = Arc>>>; type ProjectContext = Arc>>; type PendingRpc = Arc>>>>; @@ -39,7 +43,6 @@ const RPC_TIMEOUT: Duration = Duration::from_secs(10); const EDITOR_RPC_TIMEOUT: Duration = Duration::from_secs(90); const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; const MAX_RPC_BYTES: usize = 2 * 1024 * 1024; -pub(crate) const AGC_PLUGIN_STATE_CHANGED_EVENT: &str = "game-creator-plugin-state-changed"; const KNOWN_PERMISSIONS: &[&str] = &[ "events.subscribe", @@ -477,32 +480,6 @@ fn plugin_root(config_dir: &Path) -> Result { Ok(root) } -/// 解析插件工作区目录:环境变量优先,其次随包资源目录 `plugins/`, -/// 开发构建再回退仓库里的 `plugins/` 工作区。 -pub(crate) fn resolve_plugin_workspace(app: &tauri::AppHandle) -> Option { - if let Some(workspace) = std::env::var_os("AGC_PLUGIN_WORKSPACE") { - let workspace = PathBuf::from(workspace); - if workspace.is_dir() { - return Some(workspace); - } - } - if let Ok(resource_dir) = app.path().resource_dir() { - let bundled = resource_dir.join("plugins"); - if bundled.is_dir() { - return Some(bundled); - } - } - #[cfg(debug_assertions)] - { - let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.."); - let workspace = repo_root.join("plugins"); - if workspace.is_dir() { - return Some(workspace); - } - } - None -} - /// 统一后的插件来源,屏蔽“AppData 导入”和“plugins/ 工作区”的差别。 #[derive(Clone, Debug)] struct ScannedPluginSource { @@ -1214,12 +1191,6 @@ impl PluginHost { self.summary_locked(record) } - pub(crate) fn reload(&self, id: &str) -> Result { - let _ = self.stop(id)?; - self.refresh()?; - self.start(id) - } - /// 内置插件的可用开关:禁用时先停进程,再持久化状态并重新扫描。 /// /// 该状态同时被 Agent 工具目录消费,禁用后插件不能启动,对应 Runtime 工具 @@ -1903,127 +1874,6 @@ impl PluginHost { } } -#[tauri::command] -pub(crate) fn list_agc_plugins(host: State<'_, PluginHost>) -> Result, String> { - host.list() -} - -#[tauri::command] -pub(crate) fn list_agc_extensions( - host: State<'_, PluginHost>, -) -> Result, String> { - host.list_extensions() -} - -fn emit_plugin_state_changed(app: &tauri::AppHandle, host: &PluginHost) { - if let Ok(extensions) = host.list_extensions() { - let _ = app.emit(AGC_PLUGIN_STATE_CHANGED_EVENT, extensions); - } -} - -#[tauri::command] -pub(crate) fn refresh_agc_plugins( - host: State<'_, PluginHost>, -) -> Result, String> { - host.refresh() -} - -#[tauri::command] -pub(crate) fn start_agc_plugin( - id: String, - host: State<'_, PluginHost>, - app: tauri::AppHandle, -) -> Result { - let result = host.start(id.trim()); - if result.is_ok() { - emit_plugin_state_changed(&app, &host); - } - result -} - -#[tauri::command] -pub(crate) fn stop_agc_plugin( - id: String, - host: State<'_, PluginHost>, - app: tauri::AppHandle, -) -> Result { - let result = host.stop(id.trim()); - if result.is_ok() { - emit_plugin_state_changed(&app, &host); - } - result -} - -#[tauri::command] -pub(crate) fn reload_agc_plugin( - id: String, - host: State<'_, PluginHost>, - app: tauri::AppHandle, -) -> Result { - let result = host.reload(id.trim()); - if result.is_ok() { - emit_plugin_state_changed(&app, &host); - } - result -} - -#[tauri::command] -pub(crate) fn set_agc_plugin_enabled( - id: String, - enabled: bool, - host: State<'_, PluginHost>, - app: tauri::AppHandle, -) -> Result, String> { - let result = host.set_enabled(id.trim(), enabled); - if result.is_ok() { - emit_plugin_state_changed(&app, &host); - } - result -} - -#[tauri::command] -pub(crate) async fn call_agc_plugin( - id: String, - method: String, - params: Value, - app: tauri::AppHandle, -) -> Result { - if !valid_text(&method, 120) || method.chars().any(char::is_control) { - return Err("插件 RPC 方法无效".to_string()); - } - tauri::async_runtime::spawn_blocking(move || { - app.state::().call(id.trim(), method, params) - }) - .await - .map_err(|_| "插件 RPC 任务失败".to_string())? -} - -#[tauri::command] -pub(crate) fn read_agc_plugin_panel( - id: String, - panel_id: String, - host: State<'_, PluginHost>, -) -> Result { - host.read_panel(&id, &panel_id) -} - -#[tauri::command] -pub(crate) async fn set_agc_plugin_project_path( - project_path: Option, - app: tauri::AppHandle, -) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - let host = app.state::(); - let result = host.set_active_project(project_path); - if result.is_ok() { - emit_plugin_state_changed(&app, &host); - } - result - }) - .await - .map_err(|_| "切换插件项目上下文任务失败".to_string())? -} - #[cfg(test)] mod tests { use super::*; @@ -2431,7 +2281,18 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p plugin.id == "agc-unity-editor" && plugin.commands.len() == 1 && plugin.capabilities.len() == 1 - }) { + }) && host.state.lock().unwrap().plugins["agc-unity-editor"] + .running + .as_ref() + .unwrap() + .registrations + .lock() + .unwrap() + .subscriptions + .values() + .any(|event| event == "project.changed") + { + // 后续切换项目会发送事件;先订阅,避免注册完成后丢失项目通知。 break; } assert!(Instant::now() < deadline); @@ -2691,11 +2552,23 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p && plugin.panels.len() == 1 }); if registered { - break; + // 注册完成早于项目订阅快照到达;只读查询确认插件已收到上下文。 + match host.call( + "agc-cocos-editor", + "cocos.editor.connection".to_string(), + json!({"operation": "status", "processId": 1}), + ) { + Ok(response) => { + assert_eq!(response["params"]["projectPath"], project); + break; + } + Err(error) if error.contains("当前没有受控项目路径") => {} + Err(error) => panic!("cocos context readiness rpc: {error}"), + } } assert!( std::time::Instant::now() < deadline, - "Cocos 插件未在期限内完成注册" + "Cocos 插件未在期限内完成注册及项目上下文初始化" ); thread::sleep(Duration::from_millis(25)); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs new file mode 100644 index 000000000..b1856306d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs @@ -0,0 +1,159 @@ +use super::*; +use tauri::{Emitter, Manager, State}; + +pub(crate) const AGC_PLUGIN_STATE_CHANGED_EVENT: &str = "game-creator-plugin-state-changed"; + +/// 解析插件工作区目录:环境变量优先,其次随包资源目录 `plugins/`, +/// 开发构建再回退仓库里的 `plugins/` 工作区。 +pub(crate) fn resolve_plugin_workspace(app: &tauri::AppHandle) -> Option { + if let Some(workspace) = std::env::var_os("AGC_PLUGIN_WORKSPACE") { + let workspace = PathBuf::from(workspace); + if workspace.is_dir() { + return Some(workspace); + } + } + if let Ok(resource_dir) = app.path().resource_dir() { + let bundled = resource_dir.join("plugins"); + if bundled.is_dir() { + return Some(bundled); + } + } + #[cfg(debug_assertions)] + { + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.."); + let workspace = repo_root.join("plugins"); + if workspace.is_dir() { + return Some(workspace); + } + } + None +} + +impl PluginHost { + pub(crate) fn reload(&self, id: &str) -> Result { + let _ = self.stop(id)?; + self.refresh()?; + self.start(id) + } +} + +#[tauri::command] +pub(crate) fn list_agc_plugins(host: State<'_, PluginHost>) -> Result, String> { + host.list() +} + +#[tauri::command] +pub(crate) fn list_agc_extensions( + host: State<'_, PluginHost>, +) -> Result, String> { + host.list_extensions() +} + +#[tauri::command] +pub(crate) fn refresh_agc_plugins( + host: State<'_, PluginHost>, +) -> Result, String> { + host.refresh() +} + +#[tauri::command] +pub(crate) fn start_agc_plugin( + id: String, + host: State<'_, PluginHost>, + app: tauri::AppHandle, +) -> Result { + let result = host.start(id.trim()); + if result.is_ok() { + emit_plugin_state_changed(&app, &host); + } + result +} + +#[tauri::command] +pub(crate) fn stop_agc_plugin( + id: String, + host: State<'_, PluginHost>, + app: tauri::AppHandle, +) -> Result { + let result = host.stop(id.trim()); + if result.is_ok() { + emit_plugin_state_changed(&app, &host); + } + result +} + +#[tauri::command] +pub(crate) fn reload_agc_plugin( + id: String, + host: State<'_, PluginHost>, + app: tauri::AppHandle, +) -> Result { + let result = host.reload(id.trim()); + if result.is_ok() { + emit_plugin_state_changed(&app, &host); + } + result +} + +#[tauri::command] +pub(crate) fn set_agc_plugin_enabled( + id: String, + enabled: bool, + host: State<'_, PluginHost>, + app: tauri::AppHandle, +) -> Result, String> { + let result = host.set_enabled(id.trim(), enabled); + if result.is_ok() { + emit_plugin_state_changed(&app, &host); + } + result +} + +#[tauri::command] +pub(crate) async fn call_agc_plugin( + id: String, + method: String, + params: Value, + app: tauri::AppHandle, +) -> Result { + if !valid_text(&method, 120) || method.chars().any(char::is_control) { + return Err("插件 RPC 方法无效".to_string()); + } + tauri::async_runtime::spawn_blocking(move || { + app.state::().call(id.trim(), method, params) + }) + .await + .map_err(|_| "插件 RPC 任务失败".to_string())? +} + +#[tauri::command] +pub(crate) fn read_agc_plugin_panel( + id: String, + panel_id: String, + host: State<'_, PluginHost>, +) -> Result { + host.read_panel(&id, &panel_id) +} + +#[tauri::command] +pub(crate) async fn set_agc_plugin_project_path( + project_path: Option, + app: tauri::AppHandle, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let host = app.state::(); + let result = host.set_active_project(project_path); + if result.is_ok() { + emit_plugin_state_changed(&app, &host); + } + result + }) + .await + .map_err(|_| "切换插件项目上下文任务失败".to_string())? +} + +fn emit_plugin_state_changed(app: &tauri::AppHandle, host: &PluginHost) { + if let Ok(extensions) = host.list_extensions() { + let _ = app.emit(AGC_PLUGIN_STATE_CHANGED_EVENT, extensions); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index 390961292..8ca838f6d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -1,5 +1,11 @@ use super::*; +// 桌面 Tauri 命令接线不参与 Rust 单元测试;registry 与预览生命周期实现仍在本模块。 +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::*; + #[derive(Clone, Default)] pub(crate) struct PreviewRegistry { current: Arc>>, @@ -721,20 +727,6 @@ pub(crate) fn filter_preview_status_for_project( } } -#[tauri::command] -pub(crate) fn start_local_game_preview( - project_path: String, - expected_revision: Option, - preview_source: Option, - registry: tauri::State<'_, PreviewRegistry>, -) -> Result { - let root = Path::new(project_path.trim()); - let capture = (preview_source == Some(crate::analytics::contract::PreviewSource::User)) - .then(crate::analytics::gui::capture_writer_context) - .flatten(); - start_local_game_preview_with_capture(root, expected_revision, ®istry, capture) -} - pub(crate) fn start_local_game_preview_at( root: &Path, registry: &PreviewRegistry, @@ -821,25 +813,6 @@ fn start_local_game_preview_with_capture( #[path = "preview_analytics_tests.rs"] mod analytics_tests; -#[tauri::command] -pub(crate) fn stop_local_game_preview( - project_path: Option, - registry: tauri::State<'_, PreviewRegistry>, -) -> Result { - let project_path = project_path - .as_deref() - .map(str::trim) - .filter(|path| !path.is_empty()); - let root = project_path.map(Path::new); - let _lock = if let Some(root) = root { - enforce_project_permission_policy(root, "preview.stop")?; - Some(acquire_project_write_lock(root, "preview.stop")?) - } else { - None - }; - stop_local_game_preview_for_root(root, ®istry) -} - pub(crate) fn stop_local_game_preview_for_root( root: Option<&Path>, registry: &PreviewRegistry, @@ -883,19 +856,6 @@ pub(crate) fn stop_local_game_preview_on_exit(registry: &PreviewRegistry) -> Res stop_local_game_preview_for_root(Some(Path::new(root)), registry).map(|_| ()) } -#[tauri::command] -pub(crate) fn stop_local_game_preview_if_matches( - project_path: String, - expected_preview: LocalPreviewResult, - registry: tauri::State<'_, PreviewRegistry>, -) -> Result { - stop_local_game_preview_if_matches_at( - Path::new(project_path.trim()), - &expected_preview, - ®istry, - ) -} - pub(crate) fn stop_local_game_preview_if_matches_at( root: &Path, expected_preview: &LocalPreviewResult, @@ -923,14 +883,6 @@ pub(crate) fn stop_local_game_preview_if_matches_at( Ok(true) } -#[tauri::command] -pub(crate) fn get_local_game_preview_status( - registry: tauri::State<'_, PreviewRegistry>, - project_path: Option, -) -> Result { - get_local_game_preview_status_at(®istry, project_path.as_deref()) -} - pub(crate) fn get_local_game_preview_status_at( registry: &PreviewRegistry, project_path: Option<&str>, @@ -945,21 +897,6 @@ pub(crate) fn get_local_game_preview_status_at( )) } -#[tauri::command] -pub(crate) async fn get_local_game_project_revision( - project_path: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || get_local_game_project_revision_sync(project_path)) - .await - .map_err(|error| format!("读取项目版本后台任务失败:{error}"))? -} - -pub(crate) fn get_local_game_project_revision_sync( - project_path: String, -) -> Result { - get_local_game_project_revision_at(Path::new(project_path.trim())) -} - pub(crate) fn get_local_game_project_revision_at( root: &Path, ) -> Result { @@ -970,17 +907,6 @@ pub(crate) fn get_local_game_project_revision_at( }) } -#[tauri::command] -pub(crate) fn activate_local_game_preview( - registry: tauri::State<'_, PreviewRegistry>, - project_path: Option, -) -> Result { - let status = registry.status(); - validate_preview_open_project(&status, project_path.as_deref())?; - preview_open_url(&status)?; - Ok(status) -} - pub(crate) fn start_local_game_preview_for_project( root: &Path, ) -> Result<(LocalPreviewResult, mpsc::Sender<()>), String> { @@ -1843,9 +1769,7 @@ mod tests { #[test] fn npm_preview_requires_build_and_prefers_bundled_assets() { - let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp"); - fs::create_dir_all(&base).unwrap(); - let root = tempfile::tempdir_in(base).unwrap(); + let root = tempfile::tempdir().unwrap(); fs::write(root.path().join("package.json"), "{}").unwrap(); fs::write(root.path().join("index.html"), "source").unwrap(); assert!(resolve_preview_path(root.path(), "/").is_err()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/preview/desktop.rs new file mode 100644 index 000000000..0d0bc016b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/preview/desktop.rs @@ -0,0 +1,81 @@ +use super::*; + +#[tauri::command] +pub(crate) fn start_local_game_preview( + project_path: String, + expected_revision: Option, + preview_source: Option, + registry: tauri::State<'_, PreviewRegistry>, +) -> Result { + let root = Path::new(project_path.trim()); + let capture = (preview_source == Some(crate::analytics::contract::PreviewSource::User)) + .then(crate::analytics::gui::capture_writer_context) + .flatten(); + start_local_game_preview_with_capture(root, expected_revision, ®istry, capture) +} + +#[tauri::command] +pub(crate) fn stop_local_game_preview( + project_path: Option, + registry: tauri::State<'_, PreviewRegistry>, +) -> Result { + let project_path = project_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()); + let root = project_path.map(Path::new); + let _lock = if let Some(root) = root { + enforce_project_permission_policy(root, "preview.stop")?; + Some(acquire_project_write_lock(root, "preview.stop")?) + } else { + None + }; + stop_local_game_preview_for_root(root, ®istry) +} + +#[tauri::command] +pub(crate) fn stop_local_game_preview_if_matches( + project_path: String, + expected_preview: LocalPreviewResult, + registry: tauri::State<'_, PreviewRegistry>, +) -> Result { + stop_local_game_preview_if_matches_at( + Path::new(project_path.trim()), + &expected_preview, + ®istry, + ) +} + +#[tauri::command] +pub(crate) fn get_local_game_preview_status( + registry: tauri::State<'_, PreviewRegistry>, + project_path: Option, +) -> Result { + get_local_game_preview_status_at(®istry, project_path.as_deref()) +} + +#[tauri::command] +pub(crate) async fn get_local_game_project_revision( + project_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || get_local_game_project_revision_sync(project_path)) + .await + .map_err(|error| format!("读取项目版本后台任务失败:{error}"))? +} + +fn get_local_game_project_revision_sync( + project_path: String, +) -> Result { + get_local_game_project_revision_at(Path::new(project_path.trim())) +} + +#[tauri::command] +pub(crate) fn activate_local_game_preview( + registry: tauri::State<'_, PreviewRegistry>, + project_path: Option, +) -> Result { + let status = registry.status(); + validate_preview_open_project(&status, project_path.as_deref())?; + preview_open_url(&status)?; + Ok(status) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs index 620e3dc77..6d086e49c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs @@ -587,6 +587,8 @@ pub(crate) fn process_session_boot_id() -> &'static str { .as_str() } +// Runner 启动时绑定 owner;共享的会话归属与清理逻辑仍参与测试。 +#[cfg(not(test))] pub(crate) fn initialize_process_session_boot_id(boot_id: &str) -> Result<(), String> { let boot_id = boot_id.trim(); if boot_id.is_empty() diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index b66b1de9d..85ab8fd04 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -23,6 +23,7 @@ mod version_resource_replacement; mod write_lock; pub(crate) use agent_db::*; +#[cfg(not(test))] pub(crate) use asset_export::*; pub(crate) use asset_rename::*; pub(crate) use bootstrap::*; @@ -34,6 +35,7 @@ pub(crate) use filesystem::*; pub(crate) use manifest::*; pub(crate) use memory::*; pub(crate) use model_usage::*; +#[cfg(not(test))] pub(crate) use resource_dependency_graph::*; pub(crate) use resource_editor::*; pub(crate) use resource_layout::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs index 43fe2df3e..7ebf7e7d1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs @@ -452,6 +452,7 @@ fn write_sparse_agent_db_with_complete_tail(root: &Path, length: u64) { } } +#[cfg(unix)] fn assert_agent_db_read_and_append_rejected(root: &Path) { let read_error = read_agent_db_records_bounded(root, 1024) .expect_err("linked Agent DB read must be rejected"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs index 9dab8ecf4..b04384118 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs @@ -9,6 +9,7 @@ use super::filesystem::validate_portable_project_path_component; /// /// `expectedProjectId` / `expectedProjectRevision` 与删除、分类更新同口径:陈旧客户端 /// 不允许凭旧 revision 改写素材文件与 manifest。 +#[cfg(not(test))] #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct RenameLocalProjectAssetInput { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 873305801..d491900c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -2581,6 +2581,7 @@ pub(crate) fn sanitize_file_name(file_name: &str) -> String { } } +#[cfg(not(test))] pub(crate) fn parse_asset_source_kind( value: &str, ) -> Result { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs index 78ca10a5d..09d81b19f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs @@ -69,19 +69,24 @@ fn asset_classification_update_writes_category_and_normalized_tags() { .expect("read revision before") .revision; + // 正式 camelCase 请求解码后仍须绑定同一项目、revision 和素材身份。 + let input: UpdateLocalProjectResourceClassificationInput = + serde_json::from_value(serde_json::json!({ + "projectPath": root.to_string_lossy(), + "expectedProjectId": project_id, + "expectedProjectRevision": revision_before, + "assetId": asset_id, + "category": "scene", + "tags": [" 场景 ", "", " ", "场景", "主舞台"], + })) + .expect("decode classification request"); let result = update_manifest_asset_classification_at( - &root, - &project_id, - revision_before, - &asset_id, - "scene", - vec![ - " 场景 ".to_string(), - String::new(), - " ".to_string(), - "场景".to_string(), - "主舞台".to_string(), - ], + Path::new(&input.project_path), + &input.expected_project_id, + input.expected_project_revision, + &input.asset_id, + &input.category, + input.tags, ) .expect("update classification"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs index 88f18ef86..3c10d0a02 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs @@ -1,6 +1,7 @@ use super::*; use std::collections::{BTreeMap, BTreeSet}; +#[cfg(not(test))] const RESOURCE_GRAPH_AGENT_DB_READ_BYTES: u64 = 32 * 1024 * 1024; #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] @@ -612,6 +613,8 @@ pub(crate) fn build_project_resource_graph( } } +// 桌面读取接入;依赖图构造与有界记录读取分别保留在共享实现中验证。 +#[cfg(not(test))] pub(crate) fn read_project_resource_graph_at( root: &Path, expected_project_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 923e1c5d3..472dad7a1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -8523,6 +8523,77 @@ mod tests { } } + #[tokio::test] + async fn audio_dispatch_uses_current_revision_and_preserves_identity_on_remote_failure() { + let directory = tempfile::tempdir().expect("audio dispatch fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "音频派发基线测试") + .expect("initialize project"); + let operation_id = Uuid::new_v4().to_string(); + let idempotency_key = Uuid::new_v4().to_string(); + let request = prepare_local_project_audio_generation( + &operation_id, + "background-music", + "平静的钢琴曲", + "背景音乐", + &idempotency_key, + ) + .expect("prepare audio submission"); + // 提交后、后台派发前发生编辑:派发必须读取此时的 revision。 + let mut revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read submission revision"); + revision.revision += 1; + write_game_creator_agent_runtime_project_revision(root, &revision) + .expect("advance revision before dispatch"); + let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest before"); + let listener = TcpListener::bind("127.0.0.1:0").expect("audio API fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let server = std::thread::spawn(move || { + let mut stream = + accept_resource_editor_fixture_connection(&listener, "audio dispatch", 0); + let request = read_http_request(&mut stream); + assert!( + request.starts_with("GET /api/external/v1/editor/projects?view=summary "), + "{request}" + ); + write_json( + &mut stream, + "503 Service Unavailable", + serde_json::json!({"error":{"code":"UNAVAILABLE","message":"fixture unavailable"}}), + ); + }); + let error = with_test_external_editor_credentials(&base_url, "audio-fixture-key", async { + run_local_project_audio_generation_at(root.to_str().expect("project path"), &request) + .await + }) + .await + .expect_err("remote context failure must stop dispatch"); + server.join().expect("audio fixture completed"); + assert!(error.contains("读取外部画布项目"), "{error}"); + let ledger = read_resource_edit_ledger(root, &operation_id) + .expect("read audio ledger") + .expect("prepared audio ledger"); + assert_eq!(ledger.expected_project_revision, revision.revision); + assert_eq!(ledger.operation_id, operation_id); + assert_eq!(ledger.idempotency_key, idempotency_key); + assert_eq!( + ledger.generation_mode, + LocalProjectResourceGenerationMode::Create + ); + assert_eq!(ledger.phase, ResourceEditLedgerPhase::Prepared); + assert!(ledger.remote_operation_id.is_none()); + assert_eq!( + fs::read(root.join(".agent/manifest.json")).unwrap(), + manifest_before + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision, + revision.revision, + ); + } + #[tokio::test] async fn canvas_context_transport_failure_stays_prepared_before_resume() { let directory = tempfile::tempdir().expect("create transport failure fixture"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs b/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs index 9da8b13de..41036d92d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs @@ -33,6 +33,7 @@ const VERSION_REPLACEMENT_MAX_ID_CHARS: usize = 512; /// 直接替换的审计记录类型,与 `asset.register` / `asset.update` 同族。 const VERSION_BINDING_REPLACE_RECORD_TYPE: &str = "asset.version_binding.replace"; +#[cfg(not(test))] #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct ReadLocalProjectVersionReplacementCandidatesInput { @@ -93,6 +94,7 @@ pub(crate) struct ReadLocalProjectVersionReplacementCandidatesResult { pub(crate) candidates: Vec, } +#[cfg(not(test))] #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct ReplaceLocalProjectVersionResourceInput { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/desktop.rs new file mode 100644 index 000000000..f3d25bca5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/desktop.rs @@ -0,0 +1,242 @@ +use super::*; +use tauri::Manager; + +/// 应用退出前等待在途同步完成的上限。 +const PROJECT_SNAPSHOT_EXIT_BUDGET_SECONDS: u64 = 15; +const PROJECT_SNAPSHOT_DEFAULT_SYNC_INTERVAL_SECONDS: u64 = 300; +const PROJECT_SNAPSHOT_SYNC_INTERVAL_ENV: &str = + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_SYNC_INTERVAL_SECONDS"; + +fn project_snapshot_sync_interval() -> Duration { + let seconds = std::env::var(PROJECT_SNAPSHOT_SYNC_INTERVAL_ENV) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|seconds| *seconds > 0) + .unwrap_or(PROJECT_SNAPSHOT_DEFAULT_SYNC_INTERVAL_SECONDS); + Duration::from_secs(seconds) +} + +pub(crate) fn request_project_snapshot_sync( + project_root: PathBuf, + trigger: ProjectSnapshotSyncTrigger, +) { + if !project_snapshot_sync_enabled() { + return; + } + let key = project_snapshot_sync_key(&project_root); + let spawn = spawn_project_snapshot_sync_task(move || { + let run = || sync_project_snapshot_blocking(&project_root, trigger); + let outcome = if matches!(trigger, ProjectSnapshotSyncTrigger::Periodic) { + try_run_project_snapshot_sync(&key, run) + } else { + Some(run_project_snapshot_sync(&key, run)) + }; + if let Some(Err(error)) = outcome { + let error = error.replace(project_root.to_string_lossy().as_ref(), ""); + app_log!( + "project_snapshot.sync.failed projectKey={:016x} trigger={}: {error}", + fnv1a64(key.as_bytes()), + trigger.as_str() + ); + } + }); + if let Err(error) = spawn { + app_log!("project_snapshot.sync.spawn.failed: {error}"); + } +} + +fn sync_project_snapshot_blocking( + project_root: &Path, + trigger: ProjectSnapshotSyncTrigger, +) -> Result { + tauri::async_runtime::block_on(sync_project_snapshot_async(project_root, trigger)) +} + +/// 应用退出前等待在途同步收尾。 +/// +/// 退出时刻窗口已销毁,按窗口重新枚举项目只会得到空集,因此这里不重复发起同步: +/// 关窗触发的同步已经在 `CloseRequested` 时启动,退出路径只需要给它一个有界窗口。 +pub(crate) fn wait_for_project_snapshot_syncs_on_exit() { + if !project_snapshot_sync_enabled() { + return; + } + if !wait_for_project_snapshot_syncs(Duration::from_secs(PROJECT_SNAPSHOT_EXIT_BUDGET_SECONDS)) { + app_log!("project_snapshot.sync.exit.budget-exhausted"); + } +} + +/// 周期定时器:只为当前窗口登记的项目触发,不扫描其它本地项目。 +pub(crate) fn spawn_project_snapshot_scheduler(app: tauri::AppHandle) { + if !project_snapshot_sync_enabled() { + app_log!("project_snapshot.scheduler.disabled"); + return; + } + let interval = project_snapshot_sync_interval(); + let spawned = std::thread::Builder::new() + .name("agc-project-snapshot-scheduler".to_string()) + .spawn(move || loop { + std::thread::sleep(interval); + for project_path in open_project_snapshot_workspaces(&app) { + request_project_snapshot_sync( + PathBuf::from(project_path), + ProjectSnapshotSyncTrigger::Periodic, + ); + } + }); + if let Err(error) = spawned { + app_log!("project_snapshot.scheduler.spawn.failed: {error}"); + } +} + +fn resolve_project_snapshot_root(project_path: &str) -> Result { + let trimmed = project_path.trim(); + if trimmed.is_empty() { + return Err("请提供项目绝对路径".to_string()); + } + let root = PathBuf::from(trimmed); + validate_project_root(&root)?; + Ok(root) +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectSnapshotStateView { + project_id: String, + index_path: String, + index_present: bool, + file_count: usize, + sync_revision: u64, + synced_at_ms: u64, + enabled: bool, +} + +/// 手动触发一次项目快照同步。同步本身跑在阻塞工作线程上,不占用窗口线程。 +#[tauri::command] +pub(crate) async fn sync_local_project_snapshot( + project_path: String, +) -> Result { + let root = resolve_project_snapshot_root(&project_path)?; + let key = project_snapshot_sync_key(&root); + tauri::async_runtime::spawn_blocking(move || { + run_project_snapshot_sync(&key, || { + sync_project_snapshot_blocking(&root, ProjectSnapshotSyncTrigger::Manual) + }) + }) + .await + .map_err(|error| format!("项目快照同步任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn read_local_project_snapshot_state( + project_path: String, +) -> Result { + let root = resolve_project_snapshot_root(&project_path)?; + let manifest = read_existing_manifest_for_project(&root)?; + let project_id = validate_project_snapshot_project_id(manifest.project_id.trim())?; + let index_path = project_snapshot_index_path(&project_id)?; + let index = read_project_snapshot_index(&project_id)?; + Ok(ProjectSnapshotStateView { + project_id, + index_path: index_path.to_string_lossy().into_owned(), + index_present: index_path.exists(), + file_count: index.files.len(), + sync_revision: index.sync_revision, + synced_at_ms: index.synced_at_ms, + enabled: project_snapshot_sync_enabled(), + }) +} + +fn project_snapshot_index_path(project_id: &str) -> Result { + Ok(project_snapshot_index_path_at( + &project_snapshot_index_directory(project_id)?, + )) +} + +static PROJECT_SNAPSHOT_WORKSPACES: OnceLock> = OnceLock::new(); + +fn project_snapshot_workspaces() -> &'static Mutex { + PROJECT_SNAPSHOT_WORKSPACES.get_or_init(|| Mutex::new(ProjectSnapshotWorkspaces::default())) +} + +/// 窗口身份由 Tauri 注入,前端只能登记自身已经打开的普通项目目录。 +#[tauri::command] +pub(crate) fn set_active_project_snapshot_workspace( + window: tauri::Window, + project_path: Option, +) -> Result<(), String> { + let project_path = project_path + .map(|path| { + let root = resolve_project_snapshot_root(&path)?; + let manifest = read_existing_manifest_for_project(&root)?; + validate_project_snapshot_project_id(manifest.project_id.trim())?; + Ok::<_, String>(root.to_string_lossy().into_owned()) + }) + .transpose() + .inspect_err(|_| { + app_log!( + "project_snapshot.workspace.registration.failed window={} reason=invalid-project", + window.label() + ); + })?; + let requests = project_snapshot_workspaces() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .set_project(window.label(), project_path); + for (root, trigger) in requests { + request_project_snapshot_sync(root, trigger); + } + Ok(()) +} + +fn open_project_snapshot_workspaces(app: &tauri::AppHandle) -> Vec { + let registry = project_snapshot_workspaces() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + // WebView URL 读取可能回到主线程,不能持登记锁等待它,否则会与关窗/登记互锁。 + let mut paths = BTreeMap::new(); + for window in app.webview_windows().into_values() { + let url_project = window + .url() + .ok() + .and_then(|url| project_snapshot_project_path_from_url(&url)); + if let Some(path) = registry.project_for_window(window.label(), url_project) { + paths.insert(project_snapshot_sync_key(Path::new(&path)), path); + } + } + paths.into_values().collect() +} + +pub(crate) fn handle_project_snapshot_window_event( + window: &tauri::Window, + event: &tauri::WindowEvent, +) { + if matches!(event, tauri::WindowEvent::Destroyed) { + project_snapshot_workspaces() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .windows + .remove(window.label()); + return; + } + if !matches!(event, tauri::WindowEvent::CloseRequested { .. }) { + return; + } + let url_project = window + .app_handle() + .get_webview_window(window.label()) + .and_then(|webview| webview.url().ok()) + .and_then(|url| project_snapshot_project_path_from_url(&url)); + let mut registry = project_snapshot_workspaces() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let project_path = registry.project_for_window(window.label(), url_project); + registry.set_project(window.label(), None); + drop(registry); + if let Some(project_path) = project_path { + request_project_snapshot_sync( + PathBuf::from(project_path), + ProjectSnapshotSyncTrigger::ProjectClose, + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs index 010642de6..d48b8a2b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs @@ -69,12 +69,6 @@ pub(crate) fn project_snapshot_index_directory_at( Ok(config_dir.join(PROJECT_SNAPSHOT_DIRECTORY).join(project_id)) } -pub(crate) fn project_snapshot_index_path(project_id: &str) -> Result { - Ok(project_snapshot_index_path_at( - &project_snapshot_index_directory(project_id)?, - )) -} - pub(crate) fn project_snapshot_index_path_at(directory: &Path) -> PathBuf { directory.join(PROJECT_SNAPSHOT_INDEX_FILE_NAME) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/lifecycle.rs index ece545897..2b0fcf466 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/lifecycle.rs @@ -3,7 +3,7 @@ use super::*; /// 显式空登记保留在表中,避免返回首页后又从窗口的旧 URL 恢复项目。 #[derive(Clone, Default)] pub(crate) struct ProjectSnapshotWorkspaces { - windows: BTreeMap>, + pub(super) windows: BTreeMap>, } impl ProjectSnapshotWorkspaces { @@ -44,92 +44,3 @@ impl ProjectSnapshotWorkspaces { requests } } - -static PROJECT_SNAPSHOT_WORKSPACES: OnceLock> = OnceLock::new(); - -fn project_snapshot_workspaces() -> &'static Mutex { - PROJECT_SNAPSHOT_WORKSPACES.get_or_init(|| Mutex::new(ProjectSnapshotWorkspaces::default())) -} - -/// 窗口身份由 Tauri 注入,前端只能登记自身已经打开的普通项目目录。 -#[tauri::command] -pub(crate) fn set_active_project_snapshot_workspace( - window: tauri::Window, - project_path: Option, -) -> Result<(), String> { - let project_path = project_path - .map(|path| { - let root = resolve_project_snapshot_root(&path)?; - let manifest = read_existing_manifest_for_project(&root)?; - validate_project_snapshot_project_id(manifest.project_id.trim())?; - Ok::<_, String>(root.to_string_lossy().into_owned()) - }) - .transpose() - .inspect_err(|_| { - app_log!( - "project_snapshot.workspace.registration.failed window={} reason=invalid-project", - window.label() - ); - })?; - let requests = project_snapshot_workspaces() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .set_project(window.label(), project_path); - for (root, trigger) in requests { - request_project_snapshot_sync(root, trigger); - } - Ok(()) -} - -pub(crate) fn open_project_snapshot_workspaces(app: &tauri::AppHandle) -> Vec { - let registry = project_snapshot_workspaces() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone(); - // WebView URL 读取可能回到主线程,不能持登记锁等待它,否则会与关窗/登记互锁。 - let mut paths = BTreeMap::new(); - for window in app.webview_windows().into_values() { - let url_project = window - .url() - .ok() - .and_then(|url| project_snapshot_project_path_from_url(&url)); - if let Some(path) = registry.project_for_window(window.label(), url_project) { - paths.insert(project_snapshot_sync_key(Path::new(&path)), path); - } - } - paths.into_values().collect() -} - -pub(crate) fn handle_project_snapshot_window_event( - window: &tauri::Window, - event: &tauri::WindowEvent, -) { - if matches!(event, tauri::WindowEvent::Destroyed) { - project_snapshot_workspaces() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .windows - .remove(window.label()); - return; - } - if !matches!(event, tauri::WindowEvent::CloseRequested { .. }) { - return; - } - let url_project = window - .app_handle() - .get_webview_window(window.label()) - .and_then(|webview| webview.url().ok()) - .and_then(|url| project_snapshot_project_path_from_url(&url)); - let mut registry = project_snapshot_workspaces() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let project_path = registry.project_for_window(window.label(), url_project); - registry.set_project(window.label(), None); - drop(registry); - if let Some(project_path) = project_path { - request_project_snapshot_sync( - PathBuf::from(project_path), - ProjectSnapshotSyncTrigger::ProjectClose, - ); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs index 503682525..97bd7907b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs @@ -9,6 +9,8 @@ use std::collections::BTreeSet; use std::sync::{Condvar, MutexGuard}; use std::time::Instant; +#[cfg(not(test))] +mod desktop; mod diff; mod index; mod lifecycle; @@ -18,6 +20,8 @@ mod transport; #[cfg(test)] mod tests; +#[cfg(not(test))] +pub(crate) use desktop::*; pub(crate) use diff::*; pub(crate) use index::*; pub(crate) use lifecycle::*; @@ -43,16 +47,12 @@ const PROJECT_SNAPSHOT_MANIFEST_ENDPOINT: &str = "/api/agc/project-snapshots/man /// 单个请求的完成上限;关闭与退出路径依赖它保持有界。 const PROJECT_SNAPSHOT_REQUEST_TIMEOUT_SECONDS: u64 = 60; -/// 应用退出前等待在途同步完成的上限。 -const PROJECT_SNAPSHOT_EXIT_BUDGET_SECONDS: u64 = 15; -const PROJECT_SNAPSHOT_DEFAULT_SYNC_INTERVAL_SECONDS: u64 = 300; -const PROJECT_SNAPSHOT_SYNC_INTERVAL_ENV: &str = - "GENARRATIVE_AGC_PROJECT_SNAPSHOT_SYNC_INTERVAL_SECONDS"; const PROJECT_SNAPSHOT_DISABLED_ENV: &str = "GENARRATIVE_AGC_PROJECT_SNAPSHOT_DISABLED"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ProjectSnapshotSyncTrigger { ProjectOpen, + #[cfg(not(test))] Periodic, ProjectClose, Manual, @@ -62,6 +62,7 @@ impl ProjectSnapshotSyncTrigger { fn as_str(self) -> &'static str { match self { Self::ProjectOpen => "project-open", + #[cfg(not(test))] Self::Periodic => "periodic", Self::ProjectClose => "project-close", Self::Manual => "manual", @@ -109,15 +110,6 @@ pub(crate) fn project_snapshot_sync_enabled() -> bool { } } -pub(crate) fn project_snapshot_sync_interval() -> Duration { - let seconds = std::env::var(PROJECT_SNAPSHOT_SYNC_INTERVAL_ENV) - .ok() - .and_then(|value| value.trim().parse::().ok()) - .filter(|seconds| *seconds > 0) - .unwrap_or(PROJECT_SNAPSHOT_DEFAULT_SYNC_INTERVAL_SECONDS); - Duration::from_secs(seconds) -} - /// 同一项目的同步串行执行;周期触发遇到在途同步直接让位,不排队堆积。 static PROJECT_SNAPSHOT_PROJECT_LOCKS: OnceLock>>>> = OnceLock::new(); @@ -232,35 +224,6 @@ fn spawn_project_snapshot_sync_task( }) } -pub(crate) fn request_project_snapshot_sync( - project_root: PathBuf, - trigger: ProjectSnapshotSyncTrigger, -) { - if !project_snapshot_sync_enabled() { - return; - } - let key = project_snapshot_sync_key(&project_root); - let spawn = spawn_project_snapshot_sync_task(move || { - let run = || sync_project_snapshot_blocking(&project_root, trigger); - let outcome = if matches!(trigger, ProjectSnapshotSyncTrigger::Periodic) { - try_run_project_snapshot_sync(&key, run) - } else { - Some(run_project_snapshot_sync(&key, run)) - }; - if let Some(Err(error)) = outcome { - let error = error.replace(project_root.to_string_lossy().as_ref(), ""); - app_log!( - "project_snapshot.sync.failed projectKey={:016x} trigger={}: {error}", - fnv1a64(key.as_bytes()), - trigger.as_str() - ); - } - }); - if let Err(error) = spawn { - app_log!("project_snapshot.sync.spawn.failed: {error}"); - } -} - pub(crate) fn project_snapshot_sync_key(project_root: &Path) -> String { let value = project_root.to_string_lossy().replace('\\', "/"); #[cfg(windows)] @@ -273,13 +236,6 @@ pub(crate) fn project_snapshot_sync_key(project_root: &Path) -> String { } } -fn sync_project_snapshot_blocking( - project_root: &Path, - trigger: ProjectSnapshotSyncTrigger, -) -> Result { - tauri::async_runtime::block_on(sync_project_snapshot_async(project_root, trigger)) -} - async fn sync_project_snapshot_async( project_root: &Path, trigger: ProjectSnapshotSyncTrigger, @@ -463,97 +419,3 @@ pub(crate) fn project_snapshot_project_path_from_url(url: &url::Url) -> Option Result { - let trimmed = project_path.trim(); - if trimmed.is_empty() { - return Err("请提供项目绝对路径".to_string()); - } - let root = PathBuf::from(trimmed); - validate_project_root(&root)?; - Ok(root) -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct ProjectSnapshotStateView { - project_id: String, - index_path: String, - index_present: bool, - file_count: usize, - sync_revision: u64, - synced_at_ms: u64, - enabled: bool, -} - -/// 手动触发一次项目快照同步。同步本身跑在阻塞工作线程上,不占用窗口线程。 -#[tauri::command] -pub(crate) async fn sync_local_project_snapshot( - project_path: String, -) -> Result { - let root = resolve_project_snapshot_root(&project_path)?; - let key = project_snapshot_sync_key(&root); - tauri::async_runtime::spawn_blocking(move || { - run_project_snapshot_sync(&key, || { - sync_project_snapshot_blocking(&root, ProjectSnapshotSyncTrigger::Manual) - }) - }) - .await - .map_err(|error| format!("项目快照同步任务失败:{error}"))? -} - -#[tauri::command] -pub(crate) fn read_local_project_snapshot_state( - project_path: String, -) -> Result { - let root = resolve_project_snapshot_root(&project_path)?; - let manifest = read_existing_manifest_for_project(&root)?; - let project_id = validate_project_snapshot_project_id(manifest.project_id.trim())?; - let index_path = project_snapshot_index_path(&project_id)?; - let index = read_project_snapshot_index(&project_id)?; - Ok(ProjectSnapshotStateView { - project_id, - index_path: index_path.to_string_lossy().into_owned(), - index_present: index_path.exists(), - file_count: index.files.len(), - sync_revision: index.sync_revision, - synced_at_ms: index.synced_at_ms, - enabled: project_snapshot_sync_enabled(), - }) -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index f1097a975..ae36b9c59 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -6,34 +6,36 @@ mod protocol; mod server; mod state; -#[allow(unused_imports)] +#[cfg(any(not(test), unix))] +pub(crate) use client::configure_external_agent_runner_read_only; +#[cfg(not(test))] pub(crate) use client::{ - attach_external_agent_runner_gui_owner, cancel_external_agent_runner_goal, - clear_external_agent_runner_platform_session, compact_external_agent_runner_context, - configure_external_agent_runner, configure_external_agent_runner_read_only, - continue_external_agent_runner_action, ensure_external_agent_runner_started, - hold_external_agent_runner_gui_participant_lock, - install_external_agent_runner_platform_session, pause_external_agent_runner, - read_external_agent_runner_status, - require_external_agent_runner_configured_for_cli_runtime_write, - require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner, - shutdown_external_agent_runner_for_gui_exit, shutdown_external_agent_runner_if_idle, - steer_external_agent_runner, wake_external_agent_runner_pending, - wake_external_agent_runner_pending_for_run, + attach_external_agent_runner_gui_owner, configure_external_agent_runner, + hold_external_agent_runner_gui_participant_lock, shutdown_external_agent_runner_for_gui_exit, }; pub(crate) use client::{ call_external_managed_editor, disconnect_external_managed_editor_project, mark_external_editor_uncertain, }; +#[allow(unused_imports)] +pub(crate) use client::{ + cancel_external_agent_runner_goal, compact_external_agent_runner_context, + continue_external_agent_runner_action, ensure_external_agent_runner_started, + pause_external_agent_runner, read_external_agent_runner_status, + require_external_agent_runner_configured_for_cli_runtime_write, + require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner, + shutdown_external_agent_runner_if_idle, steer_external_agent_runner, + wake_external_agent_runner_pending, wake_external_agent_runner_pending_for_run, +}; pub(crate) use endpoint::external_agent_runner_process_start_identity; #[cfg(windows)] pub(crate) use endpoint::validate_windows_regular_file_handle; pub(crate) use endpoint::{external_agent_runner_enabled, external_agent_runner_is_server_process}; #[allow(unused_imports)] pub(crate) use protocol::{ExternalAgentRunnerStatus, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION}; -pub(crate) use server::{ - bind_loopback_listener_with_linux_fallback, run_external_agent_runner_server, -}; +pub(crate) use server::bind_loopback_listener_with_linux_fallback; +#[cfg(not(test))] +pub(crate) use server::run_external_agent_runner_server; #[cfg(test)] pub(crate) fn simulate_external_agent_runner_cross_boot_owner_claim_for_test( @@ -71,3 +73,7 @@ pub(crate) fn simulate_external_agent_runner_cross_boot_owner_claim_for_test( } #[cfg(test)] mod tests; + +pub(crate) use client::{ + clear_external_agent_runner_platform_session, install_external_agent_runner_platform_session, +}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index f67bf93aa..3316feacf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -1,5 +1,7 @@ use super::{dispatch::*, endpoint::*, project_owner::*, protocol::*, state::*}; -use crate::{AgentRuntimeContextCompactionResult, GameCreatorManifestInvalidationEventSink}; +use crate::AgentRuntimeContextCompactionResult; +#[cfg(not(test))] +use crate::GameCreatorManifestInvalidationEventSink; use serde_json::Value; use sha2::{Digest as _, Sha256}; use std::ffi::OsString; @@ -44,6 +46,7 @@ static EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE: OnceLock< Mutex, > = OnceLock::new(); +#[cfg(not(test))] static EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK: OnceLock< Mutex>, > = OnceLock::new(); @@ -54,6 +57,7 @@ fn external_agent_runner_gui_owner_attachment_state( .get_or_init(|| Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default())) } +#[cfg(not(test))] fn external_agent_runner_gui_participant_lock( ) -> &'static Mutex> { EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK.get_or_init(|| Mutex::new(None)) @@ -63,6 +67,7 @@ fn external_agent_runner_gui_participant_lock( /// /// 参与锁是共享句柄锁:同一 AppData 的多个窗口可以同时持有,Runner 用独占探测 /// 判断是否仍有窗口存活,因此这个锁同时也是“Runner 不能先退出”的存活凭据。 +#[cfg(not(test))] pub(crate) fn hold_external_agent_runner_gui_participant_lock( config_dir: &Path, ) -> Result<(), String> { @@ -71,6 +76,7 @@ pub(crate) fn hold_external_agent_runner_gui_participant_lock( Ok(()) } +#[cfg(not(test))] fn release_external_agent_runner_gui_participant_lock() { drop(lock_unpoisoned(external_agent_runner_gui_participant_lock()).take()); } @@ -1025,6 +1031,7 @@ fn force_terminate_external_agent_runner_process( Err("当前平台不支持核验并强制终止 Agent Runner".to_string()) } +#[cfg(not(test))] pub(crate) fn attach_external_agent_runner_gui_owner( event_sink: &GameCreatorManifestInvalidationEventSink, ) -> Result<(), String> { @@ -1310,6 +1317,7 @@ fn attach_registered_external_agent_runner_gui_owner_if_needed( /// /// 返回 `Ok(false)` 表示仍检测到其它窗口持有参与锁,Runner 必须保留给它们; /// 返回 `Ok(true)` 表示本窗口是最后一个界面进程,Runner 已请求关闭。 +#[cfg(not(test))] pub(crate) fn shutdown_external_agent_runner_for_gui_exit() -> Result { let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let Some(config_dir) = external_agent_runner_config_dir() else { @@ -1436,6 +1444,7 @@ fn reuse_or_retire_external_agent_runner_endpoint( Ok(None) } +#[cfg(not(test))] pub(crate) fn configure_external_agent_runner(config_dir: impl AsRef) -> Result<(), String> { let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?; @@ -1443,6 +1452,7 @@ pub(crate) fn configure_external_agent_runner(config_dir: impl AsRef) -> R Ok(()) } +#[cfg(any(not(test), unix))] pub(crate) fn configure_external_agent_runner_read_only( config_dir: impl AsRef, ) -> Result<(), String> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index 1bb9fcce0..b1ac77ef2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -1,17 +1,20 @@ use super::{endpoint::*, project_owner::*, protocol::*, state::*}; + +#[cfg(not(test))] +mod desktop; use crate::{ register_game_creator_manifest_invalidation_event_sink, validate_game_creator_manifest_invalidation_event_sink, }; +#[cfg(not(test))] +pub(super) use desktop::handle_external_agent_runner_connection; use serde::Deserialize; use serde_json::json; use sha2::{Digest as _, Sha256}; use std::fs; use std::io::{self, Read, Write}; -use std::net::TcpStream; use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; -use std::sync::Arc; pub(super) fn read_external_agent_runner_frame( reader: &mut R, @@ -1281,64 +1284,3 @@ pub(super) fn external_agent_runner_known_roots_are_idle( } Ok(true) } - -pub(super) fn write_external_agent_runner_response( - stream: &mut TcpStream, - response: &ExternalAgentRunnerResponse, -) -> Result<(), String> { - let payload = - serde_json::to_vec(response).map_err(|_| "序列化 Agent Runner 响应失败".to_string())?; - write_external_agent_runner_frame(stream, &payload) - .map_err(|error| format!("写入 Agent Runner 响应失败:{error}"))?; - stream - .flush() - .map_err(|error| format!("刷新 Agent Runner 响应失败:{error}")) -} - -pub(super) fn handle_external_agent_runner_connection( - mut stream: TcpStream, - state: Arc, -) -> Result<(), String> { - let _active = ExternalAgentRunnerActiveConnection { state: &state }; - stream - .set_read_timeout(Some(EXTERNAL_AGENT_RUNNER_IO_TIMEOUT)) - .and_then(|_| stream.set_write_timeout(Some(EXTERNAL_AGENT_RUNNER_IO_TIMEOUT))) - .map_err(|error| format!("配置 Agent Runner 连接超时失败:{error}"))?; - - let payload = match read_external_agent_runner_frame(&mut stream) { - Ok(payload) => payload, - Err(ExternalAgentRunnerFrameError::Oversize(_)) => { - let response = ExternalAgentRunnerResponse::failure( - "", - "frame-too-large", - "Agent Runner 请求超过 1 MiB 上限", - ); - return write_external_agent_runner_response(&mut stream, &response); - } - Err(ExternalAgentRunnerFrameError::Io(error)) - if matches!( - error.kind(), - io::ErrorKind::UnexpectedEof - | io::ErrorKind::ConnectionReset - | io::ErrorKind::TimedOut - | io::ErrorKind::WouldBlock - ) => - { - return Ok(()); - } - Err(error) => return Err(format!("读取 Agent Runner 请求失败:{error}")), - }; - let request = match serde_json::from_slice::(&payload) { - Ok(request) => request, - Err(_) => { - let response = ExternalAgentRunnerResponse::failure( - "", - "invalid-json", - "Agent Runner 请求 JSON 无效", - ); - return write_external_agent_runner_response(&mut stream, &response); - } - }; - let response = handle_external_agent_runner_request(request, &state); - write_external_agent_runner_response(&mut stream, &response) -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch/desktop.rs new file mode 100644 index 000000000..cce424ac5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch/desktop.rs @@ -0,0 +1,64 @@ +use super::*; +use std::net::TcpStream; +use std::sync::Arc; + +pub(super) fn write_external_agent_runner_response( + stream: &mut TcpStream, + response: &ExternalAgentRunnerResponse, +) -> Result<(), String> { + let payload = + serde_json::to_vec(response).map_err(|_| "序列化 Agent Runner 响应失败".to_string())?; + write_external_agent_runner_frame(stream, &payload) + .map_err(|error| format!("写入 Agent Runner 响应失败:{error}"))?; + stream + .flush() + .map_err(|error| format!("刷新 Agent Runner 响应失败:{error}")) +} + +pub(in crate::runner) fn handle_external_agent_runner_connection( + mut stream: TcpStream, + state: Arc, +) -> Result<(), String> { + let _active = ExternalAgentRunnerActiveConnection { state: &state }; + stream + .set_read_timeout(Some(EXTERNAL_AGENT_RUNNER_IO_TIMEOUT)) + .and_then(|_| stream.set_write_timeout(Some(EXTERNAL_AGENT_RUNNER_IO_TIMEOUT))) + .map_err(|error| format!("配置 Agent Runner 连接超时失败:{error}"))?; + + let payload = match read_external_agent_runner_frame(&mut stream) { + Ok(payload) => payload, + Err(ExternalAgentRunnerFrameError::Oversize(_)) => { + let response = ExternalAgentRunnerResponse::failure( + "", + "frame-too-large", + "Agent Runner 请求超过 1 MiB 上限", + ); + return write_external_agent_runner_response(&mut stream, &response); + } + Err(ExternalAgentRunnerFrameError::Io(error)) + if matches!( + error.kind(), + io::ErrorKind::UnexpectedEof + | io::ErrorKind::ConnectionReset + | io::ErrorKind::TimedOut + | io::ErrorKind::WouldBlock + ) => + { + return Ok(()); + } + Err(error) => return Err(format!("读取 Agent Runner 请求失败:{error}")), + }; + let request = match serde_json::from_slice::(&payload) { + Ok(request) => request, + Err(_) => { + let response = ExternalAgentRunnerResponse::failure( + "", + "invalid-json", + "Agent Runner 请求 JSON 无效", + ); + return write_external_agent_runner_response(&mut stream, &response); + } + }; + let response = handle_external_agent_runner_request(request, &state); + write_external_agent_runner_response(&mut stream, &response) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs index 267943ae2..92d30e90a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs @@ -21,6 +21,7 @@ pub(super) fn external_agent_runner_configure_lock() -> &'static Mutex<()> { EXTERNAL_AGENT_RUNNER_CONFIGURE_LOCK.get_or_init(|| Mutex::new(())) } +#[cfg(any(not(test), unix))] pub(super) fn set_external_agent_runner_config_dir(config_dir: PathBuf) { *lock_unpoisoned(external_agent_runner_config_dir_lock()) = Some(config_dir); } @@ -290,12 +291,14 @@ pub(super) fn redact_external_agent_runner_runtime_error( crate::redact_agent_runtime_error(root, &redacted, 500) } +#[cfg(not(test))] pub(super) fn normalize_external_agent_runner_config_dir( config_dir: &Path, ) -> Result { crate::prepare_game_creator_runtime_config_dir(config_dir) } +#[cfg(any(not(test), unix))] pub(super) fn inspect_external_agent_runner_config_dir( config_dir: &Path, ) -> Result { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index 434c1c0a9..0fcd9cac9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -28,6 +28,7 @@ pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_PATH: &str = pub(super) const EXTERNAL_AGENT_RUNNER_MAX_FRAME_BYTES: usize = 4 * 1024 * 1024; pub(super) const EXTERNAL_AGENT_RUNNER_MAX_ENDPOINT_BYTES: u64 = 64 * 1024; pub(super) const EXTERNAL_AGENT_RUNNER_MAX_OWNER_BYTES: u64 = 16 * 1024; +#[cfg(not(test))] pub(super) const EXTERNAL_AGENT_RUNNER_MAX_CONNECTIONS: usize = 32; pub(super) const EXTERNAL_AGENT_RUNNER_MAX_CACHED_REQUESTS: usize = 512; pub(super) const EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE: &str = "runtime-wake-retryable"; @@ -47,11 +48,14 @@ pub(super) const EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_EXIT_TIMEOUT: Duration = Dur pub(super) const EXTERNAL_AGENT_RUNNER_CONTEXT_COMPACTION_IO_TIMEOUT: Duration = Duration::from_secs(6 * 60); pub(super) const EXTERNAL_AGENT_RUNNER_START_TIMEOUT: Duration = Duration::from_secs(30); +#[cfg(not(test))] pub(super) const EXTERNAL_AGENT_RUNNER_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2); +#[cfg(not(test))] pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_CHECK_INTERVAL: Duration = Duration::from_millis(100); pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT: Duration = Duration::from_millis(1_750); +#[cfg(not(test))] pub(super) const EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL: Duration = Duration::from_millis(25); #[cfg(target_os = "linux")] pub(super) const EXTERNAL_AGENT_RUNNER_LINUX_EPHEMERAL_PORT_RANGE_PATH: &str = diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs index 1605e5be0..8f0c445a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs @@ -1,26 +1,27 @@ -use super::{dispatch::*, endpoint::*, protocol::*, state::*}; +#[cfg(not(test))] +use super::dispatch::*; +#[cfg(test)] +use super::dispatch::{ + request_external_agent_runner_forced_shutdown, + validate_external_agent_runner_gui_owner_claim_current, +}; +#[cfg(any(not(test), target_os = "linux"))] +use super::protocol::*; +use super::{endpoint::*, state::*}; + +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::run_external_agent_runner_server; #[cfg(target_os = "linux")] use sha2::{Digest as _, Sha256}; #[cfg(target_os = "linux")] use std::fs; use std::io; use std::net::{Ipv4Addr, SocketAddrV4, TcpListener}; -use std::path::{Path, PathBuf}; +#[cfg(not(test))] +use std::path::Path; use std::sync::atomic::Ordering; -use std::sync::Arc; -use std::thread; -use std::time::{Duration, Instant}; - -pub(super) fn refresh_external_agent_runner_heartbeat( - state: &ExternalAgentRunnerServerState, -) -> Result<(), String> { - let endpoint = { - let mut endpoint = lock_unpoisoned(&state.endpoint); - endpoint.heartbeat_at = unix_millis(); - endpoint.clone() - }; - write_external_agent_runner_endpoint_atomic(&state.endpoint_path, &endpoint) -} #[cfg(any(target_os = "linux", test))] pub(super) fn bind_external_agent_runner_listener_with( @@ -178,27 +179,6 @@ pub(super) fn external_agent_runner_shutdown_if_gui_owner_lost( Ok(external_agent_runner_watchdog_tick(state)) } -pub(super) fn spawn_external_agent_runner_gui_owner_watchdog( - state: Arc, - endpoint_path: PathBuf, - boot_id: String, -) -> Result<(), String> { - thread::Builder::new() - .name("agent-runner-gui-owner-watchdog".to_string()) - .spawn(move || loop { - if !external_agent_runner_watchdog_tick(&state) { - thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_CHECK_INTERVAL); - continue; - } - thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT); - let _ = crate::agent::shutdown_game_creator_codex_app_servers(); - remove_external_agent_runner_endpoint_if_boot_matches(&endpoint_path, &boot_id); - std::process::exit(1); - }) - .map(|_| ()) - .map_err(|error| format!("启动 Agent Runner GUI owner watchdog 失败:{error}")) -} - pub(super) fn resolve_external_agent_runner_initial_gui_owner( gui_owner_required: bool, gui_owner_present: bool, @@ -208,166 +188,3 @@ pub(super) fn resolve_external_agent_runner_initial_gui_owner( } Ok(gui_owner_present) } - -pub(crate) fn run_external_agent_runner_server( - config_dir: impl AsRef, - gui_owner_required: bool, -) -> Result<(), String> { - let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?; - EXTERNAL_AGENT_RUNNER_SERVER_PROCESS.store(true, Ordering::Release); - crate::set_game_creator_runtime_config_dir(config_dir.clone()); - crate::editor_adapters::configure_unity_helper_for_runtime()?; - crate::editor_adapters::configure_godot_payload_for_runtime(&config_dir)?; - set_external_agent_runner_config_dir(config_dir.clone()); - - let boot_id = random_identifier(b"genarrative-agent-runner-boot-id")?; - crate::initialize_process_session_boot_id(&boot_id)?; - let token = random_identifier(b"genarrative-agent-runner-token")?; - let _instance_lock = acquire_external_agent_runner_instance_lock( - &external_agent_runner_lock_path(&config_dir), - &boot_id, - )?; - let gui_owner_present_at_start = resolve_external_agent_runner_initial_gui_owner( - gui_owner_required, - external_agent_runner_lock_is_held(&external_agent_runner_gui_participant_lock_path( - &config_dir, - ))?, - )?; - let executable_fingerprint = current_external_agent_runner_executable_fingerprint()?; - let listener = bind_loopback_listener_with_linux_fallback(&boot_id) - .map_err(|error| format!("绑定 Agent Runner loopback 端口失败:{error}"))?; - listener - .set_nonblocking(true) - .map_err(|error| format!("配置 Agent Runner listener 失败:{error}"))?; - let port = listener - .local_addr() - .map_err(|error| format!("读取 Agent Runner loopback 地址失败:{error}"))? - .port(); - let endpoint = ExternalAgentRunnerEndpoint { - protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, - pid: std::process::id(), - boot_id: boot_id.clone(), - port, - token, - heartbeat_at: unix_millis(), - executable_fingerprint: Some(executable_fingerprint), - process_start_identity: external_agent_runner_process_start_identity(std::process::id())?, - }; - let endpoint_path = external_agent_runner_endpoint_path(&config_dir); - write_external_agent_runner_endpoint_atomic(&endpoint_path, &endpoint)?; - let _endpoint_guard = ExternalAgentRunnerEndpointGuard { - path: endpoint_path.clone(), - boot_id, - }; - let state = Arc::new(ExternalAgentRunnerServerState::new(endpoint_path, endpoint)); - state - .gui_owner_attached - .store(gui_owner_present_at_start, Ordering::Release); - spawn_external_agent_runner_gui_owner_watchdog( - Arc::clone(&state), - state.endpoint_path.clone(), - state.endpoint_snapshot().boot_id, - )?; - let mut last_heartbeat = Instant::now(); - let mut server_error = None; - - loop { - if state.shutdown_requested.load(Ordering::Acquire) { - if state.force_shutdown_requested.load(Ordering::Acquire) - || state.active_connections.load(Ordering::Acquire) == 0 - { - break; - } - thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL); - continue; - } - - match listener.accept() { - Ok((stream, _)) => { - let previous = state.active_connections.fetch_add(1, Ordering::AcqRel); - if previous >= EXTERNAL_AGENT_RUNNER_MAX_CONNECTIONS { - state.active_connections.fetch_sub(1, Ordering::AcqRel); - drop(stream); - continue; - } - let worker_state = Arc::clone(&state); - if thread::Builder::new() - .name("agent-runner-connection".to_string()) - .spawn(move || { - let _ = handle_external_agent_runner_connection(stream, worker_state); - }) - .is_err() - { - state.active_connections.fetch_sub(1, Ordering::AcqRel); - } - } - Err(error) if error.kind() == io::ErrorKind::WouldBlock => {} - Err(error) => { - server_error = Some(format!("接受 Agent Runner 连接失败:{error}")); - break; - } - } - - if last_heartbeat.elapsed() >= EXTERNAL_AGENT_RUNNER_HEARTBEAT_INTERVAL { - if let Err(error) = refresh_external_agent_runner_heartbeat(&state) { - server_error = Some(error); - break; - } - last_heartbeat = Instant::now(); - } - thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL); - } - - state.shutdown_requested.store(true, Ordering::Release); - let forced = state.force_shutdown_requested.load(Ordering::Acquire); - let forced_deadline = - forced.then(|| Instant::now() + EXTERNAL_AGENT_RUNNER_FORCED_TOTAL_DRAIN_TIMEOUT); - let worker_deadline = Instant::now() - + if forced { - EXTERNAL_AGENT_RUNNER_FORCED_WORKER_DRAIN_TIMEOUT - } else { - EXTERNAL_AGENT_RUNNER_IO_TIMEOUT - }; - while state.active_connections.load(Ordering::Acquire) > 0 && Instant::now() < worker_deadline { - thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL); - } - let forced_roots = forced.then(|| { - let roots = state.known_roots_snapshot(); - crate::interrupt_game_creator_agent_runtime_provider_requests_for_roots(&roots); - roots - }); - let process_timeout = forced_deadline - .map(|deadline| deadline.saturating_duration_since(Instant::now())) - .unwrap_or(Duration::from_secs(3)); - let mut process_shutdown = crate::shutdown_all_process_sessions_and_wait(process_timeout); - if forced { - let roots = forced_roots.as_deref().unwrap_or_default(); - let provider_deadline = forced_deadline.expect("forced shutdown has a deadline"); - while crate::game_creator_agent_runtime_provider_request_count_for_roots(roots) > 0 - && Instant::now() < provider_deadline - { - thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL); - } - if crate::game_creator_agent_runtime_provider_request_count_for_roots(roots) > 0 { - let provider_error = "Runner 退出前未能中断全部 Provider 请求".to_string(); - process_shutdown = Err(match process_shutdown { - Ok(()) => provider_error, - Err(process_error) => format!("{process_error};{provider_error}"), - }); - } - } - if let Err(app_server_error) = crate::agent::shutdown_game_creator_codex_app_servers() { - process_shutdown = Err(match process_shutdown { - Ok(()) => app_server_error, - Err(process_error) => format!("{process_error};{app_server_error}"), - }); - } - if let Some(error) = server_error { - Err(match process_shutdown { - Ok(()) => error, - Err(process_error) => format!("{error};{process_error}"), - }) - } else { - process_shutdown - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/server/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/server/desktop.rs new file mode 100644 index 000000000..aafe14438 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/server/desktop.rs @@ -0,0 +1,200 @@ +use super::*; +use std::path::PathBuf; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +pub(super) fn refresh_external_agent_runner_heartbeat( + state: &ExternalAgentRunnerServerState, +) -> Result<(), String> { + let endpoint = { + let mut endpoint = lock_unpoisoned(&state.endpoint); + endpoint.heartbeat_at = unix_millis(); + endpoint.clone() + }; + write_external_agent_runner_endpoint_atomic(&state.endpoint_path, &endpoint) +} + +pub(super) fn spawn_external_agent_runner_gui_owner_watchdog( + state: Arc, + endpoint_path: PathBuf, + boot_id: String, +) -> Result<(), String> { + thread::Builder::new() + .name("agent-runner-gui-owner-watchdog".to_string()) + .spawn(move || loop { + if !external_agent_runner_watchdog_tick(&state) { + thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_CHECK_INTERVAL); + continue; + } + thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT); + let _ = crate::agent::shutdown_game_creator_codex_app_servers(); + remove_external_agent_runner_endpoint_if_boot_matches(&endpoint_path, &boot_id); + std::process::exit(1); + }) + .map(|_| ()) + .map_err(|error| format!("启动 Agent Runner GUI owner watchdog 失败:{error}")) +} + +pub(crate) fn run_external_agent_runner_server( + config_dir: impl AsRef, + gui_owner_required: bool, +) -> Result<(), String> { + let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?; + EXTERNAL_AGENT_RUNNER_SERVER_PROCESS.store(true, Ordering::Release); + crate::set_game_creator_runtime_config_dir(config_dir.clone()); + crate::editor_adapters::configure_unity_helper_for_runtime()?; + crate::editor_adapters::configure_godot_payload_for_runtime(&config_dir)?; + set_external_agent_runner_config_dir(config_dir.clone()); + + let boot_id = random_identifier(b"genarrative-agent-runner-boot-id")?; + crate::initialize_process_session_boot_id(&boot_id)?; + let token = random_identifier(b"genarrative-agent-runner-token")?; + let _instance_lock = acquire_external_agent_runner_instance_lock( + &external_agent_runner_lock_path(&config_dir), + &boot_id, + )?; + let gui_owner_present_at_start = resolve_external_agent_runner_initial_gui_owner( + gui_owner_required, + external_agent_runner_lock_is_held(&external_agent_runner_gui_participant_lock_path( + &config_dir, + ))?, + )?; + let executable_fingerprint = current_external_agent_runner_executable_fingerprint()?; + let listener = bind_loopback_listener_with_linux_fallback(&boot_id) + .map_err(|error| format!("绑定 Agent Runner loopback 端口失败:{error}"))?; + listener + .set_nonblocking(true) + .map_err(|error| format!("配置 Agent Runner listener 失败:{error}"))?; + let port = listener + .local_addr() + .map_err(|error| format!("读取 Agent Runner loopback 地址失败:{error}"))? + .port(); + let endpoint = ExternalAgentRunnerEndpoint { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + pid: std::process::id(), + boot_id: boot_id.clone(), + port, + token, + heartbeat_at: unix_millis(), + executable_fingerprint: Some(executable_fingerprint), + process_start_identity: external_agent_runner_process_start_identity(std::process::id())?, + }; + let endpoint_path = external_agent_runner_endpoint_path(&config_dir); + write_external_agent_runner_endpoint_atomic(&endpoint_path, &endpoint)?; + let _endpoint_guard = ExternalAgentRunnerEndpointGuard { + path: endpoint_path.clone(), + boot_id, + }; + let state = Arc::new(ExternalAgentRunnerServerState::new(endpoint_path, endpoint)); + state + .gui_owner_attached + .store(gui_owner_present_at_start, Ordering::Release); + spawn_external_agent_runner_gui_owner_watchdog( + Arc::clone(&state), + state.endpoint_path.clone(), + state.endpoint_snapshot().boot_id, + )?; + let mut last_heartbeat = Instant::now(); + let mut server_error = None; + + loop { + if state.shutdown_requested.load(Ordering::Acquire) { + if state.force_shutdown_requested.load(Ordering::Acquire) + || state.active_connections.load(Ordering::Acquire) == 0 + { + break; + } + thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL); + continue; + } + + match listener.accept() { + Ok((stream, _)) => { + let previous = state.active_connections.fetch_add(1, Ordering::AcqRel); + if previous >= EXTERNAL_AGENT_RUNNER_MAX_CONNECTIONS { + state.active_connections.fetch_sub(1, Ordering::AcqRel); + drop(stream); + continue; + } + let worker_state = Arc::clone(&state); + if thread::Builder::new() + .name("agent-runner-connection".to_string()) + .spawn(move || { + let _ = handle_external_agent_runner_connection(stream, worker_state); + }) + .is_err() + { + state.active_connections.fetch_sub(1, Ordering::AcqRel); + } + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => {} + Err(error) => { + server_error = Some(format!("接受 Agent Runner 连接失败:{error}")); + break; + } + } + + if last_heartbeat.elapsed() >= EXTERNAL_AGENT_RUNNER_HEARTBEAT_INTERVAL { + if let Err(error) = refresh_external_agent_runner_heartbeat(&state) { + server_error = Some(error); + break; + } + last_heartbeat = Instant::now(); + } + thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL); + } + + state.shutdown_requested.store(true, Ordering::Release); + let forced = state.force_shutdown_requested.load(Ordering::Acquire); + let forced_deadline = + forced.then(|| Instant::now() + EXTERNAL_AGENT_RUNNER_FORCED_TOTAL_DRAIN_TIMEOUT); + let worker_deadline = Instant::now() + + if forced { + EXTERNAL_AGENT_RUNNER_FORCED_WORKER_DRAIN_TIMEOUT + } else { + EXTERNAL_AGENT_RUNNER_IO_TIMEOUT + }; + while state.active_connections.load(Ordering::Acquire) > 0 && Instant::now() < worker_deadline { + thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL); + } + let forced_roots = forced.then(|| { + let roots = state.known_roots_snapshot(); + crate::interrupt_game_creator_agent_runtime_provider_requests_for_roots(&roots); + roots + }); + let process_timeout = forced_deadline + .map(|deadline| deadline.saturating_duration_since(Instant::now())) + .unwrap_or(Duration::from_secs(3)); + let mut process_shutdown = crate::shutdown_all_process_sessions_and_wait(process_timeout); + if forced { + let roots = forced_roots.as_deref().unwrap_or_default(); + let provider_deadline = forced_deadline.expect("forced shutdown has a deadline"); + while crate::game_creator_agent_runtime_provider_request_count_for_roots(roots) > 0 + && Instant::now() < provider_deadline + { + thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL); + } + if crate::game_creator_agent_runtime_provider_request_count_for_roots(roots) > 0 { + let provider_error = "Runner 退出前未能中断全部 Provider 请求".to_string(); + process_shutdown = Err(match process_shutdown { + Ok(()) => provider_error, + Err(process_error) => format!("{process_error};{provider_error}"), + }); + } + } + if let Err(app_server_error) = crate::agent::shutdown_game_creator_codex_app_servers() { + process_shutdown = Err(match process_shutdown { + Ok(()) => app_server_error, + Err(process_error) => format!("{process_error};{app_server_error}"), + }); + } + if let Some(error) = server_error { + Err(match process_shutdown { + Ok(()) => error, + Err(process_error) => format!("{error};{process_error}"), + }) + } else { + process_shutdown + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs index 5991626b1..28af0306d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs @@ -3,7 +3,9 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs::{self, File}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +#[cfg(not(test))] +use std::sync::atomic::Ordering; +use std::sync::atomic::{AtomicBool, AtomicUsize}; use std::sync::{Condvar, Mutex, MutexGuard}; pub(super) struct ExternalAgentRunnerServerState { @@ -217,10 +219,12 @@ impl ExternalAgentRunnerServerState { } } +#[cfg(not(test))] pub(super) struct ExternalAgentRunnerActiveConnection<'a> { pub(super) state: &'a ExternalAgentRunnerServerState, } +#[cfg(not(test))] impl Drop for ExternalAgentRunnerActiveConnection<'_> { fn drop(&mut self) { self.state.active_connections.fetch_sub(1, Ordering::AcqRel); @@ -265,11 +269,13 @@ impl ExternalAgentRunnerProjectExecutionOwner { } } +#[cfg(not(test))] pub(super) struct ExternalAgentRunnerEndpointGuard { pub(super) path: PathBuf, pub(super) boot_id: String, } +#[cfg(not(test))] pub(super) fn remove_external_agent_runner_endpoint_if_boot_matches(path: &Path, boot_id: &str) { let Ok(endpoint) = read_external_agent_runner_endpoint(path) else { return; @@ -279,6 +285,7 @@ pub(super) fn remove_external_agent_runner_endpoint_if_boot_matches(path: &Path, } } +#[cfg(not(test))] impl Drop for ExternalAgentRunnerEndpointGuard { fn drop(&mut self) { remove_external_agent_runner_endpoint_if_boot_matches(&self.path, &self.boot_id); diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 4f372d1f3..cd0e920c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -3,6 +3,7 @@ use super::{ }; use serde_json::{json, Value}; use sha2::{Digest as _, Sha256}; +#[cfg(target_os = "linux")] use std::collections::BTreeSet; use std::ffi::OsString; use std::fs; diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index 45f9b0330..65265250e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -4,6 +4,11 @@ //! 也不改写已存在项目。远端对象只允许来自受信任 OSS 主机下的 `templates/` 前缀; //! 清单、zip 与封面一律先校验再落盘,zip 解压只接受普通文件与目录。 +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::*; + use super::*; use crate::platform_session::{ current_platform_session, validate_platform_session_identity, @@ -18,7 +23,6 @@ const TEMPLATE_LIBRARY_OSS_HOST: &str = "agc-dev.oss-rg-china-mainland.aliyuncs. const DEFAULT_TEMPLATE_LIBRARY_BASE_URL: &str = "https://agc-dev.oss-rg-china-mainland.aliyuncs.com"; const TEMPLATE_LIBRARY_BASE_URL_ENV: &str = "AGC_TEMPLATE_LIBRARY_BASE_URL"; -const TEMPLATE_CACHE_DIRECTORY_NAME: &str = "templates"; const TEMPLATE_INSTALLED_DIRECTORY_NAME: &str = "installed"; const TEMPLATE_INSTALLED_MARKER_FILE: &str = "installed.json"; const TEMPLATE_INDEX_CACHE_FILE: &str = "index.json"; @@ -179,19 +183,6 @@ pub(crate) struct GameTemplateEntry { pub(crate) installed_at_millis: Option, } -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct GameTemplateLibrarySnapshot { - pub(crate) schema_version: String, - pub(crate) library: String, - pub(crate) library_version: u32, - pub(crate) updated_at: String, - pub(crate) fetched_at_millis: u64, - /// `network` 或 `cache`:命中本机缓存时前端应提示清单可能不是最新。 - pub(crate) source: String, - pub(crate) templates: Vec, -} - #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct InstalledGameTemplateRecord { @@ -203,17 +194,6 @@ struct InstalledGameTemplateRecord { project_dir: String, } -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct InstalledGameTemplate { - pub(crate) template_id: String, - pub(crate) template_version: String, - pub(crate) installed_at_millis: u64, - pub(crate) zip_sha256: String, - pub(crate) file_count: usize, - pub(crate) project_dir: String, -} - fn now_millis() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -345,13 +325,6 @@ pub(crate) fn parse_game_template_library_index( Ok((index.header, index.templates)) } -fn template_cache_root(app: &tauri::AppHandle) -> Result { - app.path() - .app_data_dir() - .map(|root| root.join(TEMPLATE_CACHE_DIRECTORY_NAME)) - .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) -} - fn installed_templates_root(cache_root: &Path) -> PathBuf { cache_root.join(TEMPLATE_INSTALLED_DIRECTORY_NAME) } @@ -454,11 +427,13 @@ fn apply_template_library_fixtures(entries: Vec) -> Vec usize { parse_synthetic_count(std::env::var(SYNTHETIC_COUNT_ENV).ok().as_deref()) } @@ -617,15 +592,6 @@ async fn fetch_limited_bytes( Ok(body) } -fn write_cached_index(cache_root: &Path, body: &str) { - let _ = ensure_game_creator_private_directory_tree(cache_root, "模板库缓存目录"); - let _ = write_game_creator_private_file( - &cache_root.join(TEMPLATE_INDEX_CACHE_FILE), - body.as_bytes(), - "模板库清单缓存", - ); -} - fn read_cached_index(cache_root: &Path) -> Option { fs::read_to_string(cache_root.join(TEMPLATE_INDEX_CACHE_FILE)).ok() } @@ -846,85 +812,6 @@ fn resolve_template_index( } } -#[tauri::command] -pub(crate) async fn fetch_game_template_library( - app: tauri::AppHandle, -) -> Result { - let identity = require_template_library_access().await?; - let cache_root = template_cache_root(&app)?; - ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; - let index_url = format!( - "{}/{}", - template_library_base()?, - TEMPLATE_LIBRARY_INDEX_KEY - ); - let client = build_template_library_client(); - let remote = - match fetch_limited_bytes(&client, &index_url, TEMPLATE_LIBRARY_MAX_INDEX_BYTES).await { - // 响应回来了但正文不是合法 UTF-8 属于「远端答非所问」,不能用缓存掩盖。 - Ok(bytes) => { - Ok(String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?) - } - Err(error) => Err(error), - }; - let (body, source) = resolve_template_index(remote, read_cached_index(&cache_root))?; - if source == TemplateIndexSource::Network { - with_validated_platform_session_identity(&identity, || { - write_cached_index(&cache_root, &body); - Ok(()) - })?; - } - validate_platform_session_identity(&identity)?; - let (header, templates) = parse_game_template_library_index(&body)?; - let installed = collect_installed_records(&cache_root); - let entries = templates - .iter() - .map(|summary| { - to_entry( - summary, - installed_record_for(&installed, &summary.id).as_ref(), - ) - }) - .collect::, _>>()?; - let entries = apply_template_library_fixtures(entries); - Ok(GameTemplateLibrarySnapshot { - schema_version: header.schema_version, - library: header.library, - library_version: header.library_version, - updated_at: header.updated_at, - fetched_at_millis: now_millis(), - source: source.as_str().to_string(), - templates: entries, - }) -} - -#[tauri::command] -pub(crate) async fn download_game_template( - app: tauri::AppHandle, - template_id: String, - template_version: String, -) -> Result { - let identity = require_template_library_access().await?; - let cache_root = template_cache_root(&app)?; - ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; - let record = ensure_template_installed( - &cache_root, - template_id.trim(), - template_version.trim(), - &identity, - ) - .await?; - validate_platform_session_identity(&identity)?; - Ok(InstalledGameTemplate { - template_id: record.template_id, - template_version: record.template_version, - installed_at_millis: record.installed_at_millis, - zip_sha256: record.zip_sha256, - file_count: record.file_count, - project_dir: record.project_dir, - }) -} - /// 用已安装模板在自动工作区根目录下建项目:先把模板文件铺进新目录,再走标准项目初始化。 pub(crate) fn create_project_from_installed_template_at( projects_root: &Path, @@ -1023,47 +910,6 @@ pub(crate) fn create_project_from_installed_template_at( Err("自动工作区命名冲突,请重试".to_string()) } -#[tauri::command] -pub(crate) async fn create_automatic_local_game_project_from_template( - app: tauri::AppHandle, - template_id: String, - template_version: String, - name: Option, - planning: Option, - projects_root: Option, -) -> Result { - let analytics_context = crate::analytics::gui::capture_analytics_context(); - let identity = require_template_library_access().await?; - let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?; - let cache_root = template_cache_root(&app)?; - ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; - let record = ensure_template_installed( - &cache_root, - template_id.trim(), - template_version.trim(), - &identity, - ) - .await?; - let result = with_validated_platform_session_identity(&identity, || { - create_project_from_installed_template_at( - &projects_root, - Path::new(&record.project_dir), - name.as_deref(), - planning.unwrap_or(false), - ) - }); - if let Ok(project) = &result { - crate::analytics::gui::created( - analytics_context, - Path::new(&project.project_path), - project.manifest.project_id.clone(), - crate::analytics::contract::CreationSource::Template, - Some(template_id), - ); - } - result -} - #[cfg(test)] mod tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library/desktop.rs new file mode 100644 index 000000000..d79a26f36 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library/desktop.rs @@ -0,0 +1,164 @@ +// 桌面模板操作接入;授权、归档安装与建项规则保留在父模块供现有测试验证。 +use super::*; + +const TEMPLATE_CACHE_DIRECTORY_NAME: &str = "templates"; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GameTemplateLibrarySnapshot { + pub(crate) schema_version: String, + pub(crate) library: String, + pub(crate) library_version: u32, + pub(crate) updated_at: String, + pub(crate) fetched_at_millis: u64, + /// `network` 或 `cache`:命中本机缓存时前端应提示清单可能不是最新。 + pub(crate) source: String, + pub(crate) templates: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InstalledGameTemplate { + pub(crate) template_id: String, + pub(crate) template_version: String, + pub(crate) installed_at_millis: u64, + pub(crate) zip_sha256: String, + pub(crate) file_count: usize, + pub(crate) project_dir: String, +} + +fn template_cache_root(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|root| root.join(TEMPLATE_CACHE_DIRECTORY_NAME)) + .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) +} + +fn write_cached_index(cache_root: &Path, body: &str) { + let _ = ensure_game_creator_private_directory_tree(cache_root, "模板库缓存目录"); + let _ = write_game_creator_private_file( + &cache_root.join(TEMPLATE_INDEX_CACHE_FILE), + body.as_bytes(), + "模板库清单缓存", + ); +} + +#[tauri::command] +pub(crate) async fn fetch_game_template_library( + app: tauri::AppHandle, +) -> Result { + let identity = require_template_library_access().await?; + let cache_root = template_cache_root(&app)?; + ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; + let index_url = format!( + "{}/{}", + template_library_base()?, + TEMPLATE_LIBRARY_INDEX_KEY + ); + let client = build_template_library_client(); + let remote = + match fetch_limited_bytes(&client, &index_url, TEMPLATE_LIBRARY_MAX_INDEX_BYTES).await { + // 响应回来了但正文不是合法 UTF-8 属于「远端答非所问」,不能用缓存掩盖。 + Ok(bytes) => { + Ok(String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?) + } + Err(error) => Err(error), + }; + let (body, source) = resolve_template_index(remote, read_cached_index(&cache_root))?; + if source == TemplateIndexSource::Network { + with_validated_platform_session_identity(&identity, || { + write_cached_index(&cache_root, &body); + Ok(()) + })?; + } + validate_platform_session_identity(&identity)?; + let (header, templates) = parse_game_template_library_index(&body)?; + let installed = collect_installed_records(&cache_root); + let entries = templates + .iter() + .map(|summary| { + to_entry( + summary, + installed_record_for(&installed, &summary.id).as_ref(), + ) + }) + .collect::, _>>()?; + let entries = apply_template_library_fixtures(entries); + Ok(GameTemplateLibrarySnapshot { + schema_version: header.schema_version, + library: header.library, + library_version: header.library_version, + updated_at: header.updated_at, + fetched_at_millis: now_millis(), + source: source.as_str().to_string(), + templates: entries, + }) +} + +#[tauri::command] +pub(crate) async fn download_game_template( + app: tauri::AppHandle, + template_id: String, + template_version: String, +) -> Result { + let identity = require_template_library_access().await?; + let cache_root = template_cache_root(&app)?; + ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; + let record = ensure_template_installed( + &cache_root, + template_id.trim(), + template_version.trim(), + &identity, + ) + .await?; + validate_platform_session_identity(&identity)?; + Ok(InstalledGameTemplate { + template_id: record.template_id, + template_version: record.template_version, + installed_at_millis: record.installed_at_millis, + zip_sha256: record.zip_sha256, + file_count: record.file_count, + project_dir: record.project_dir, + }) +} + +#[tauri::command] +pub(crate) async fn create_automatic_local_game_project_from_template( + app: tauri::AppHandle, + template_id: String, + template_version: String, + name: Option, + planning: Option, + projects_root: Option, +) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); + let identity = require_template_library_access().await?; + let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?; + let cache_root = template_cache_root(&app)?; + ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; + let record = ensure_template_installed( + &cache_root, + template_id.trim(), + template_version.trim(), + &identity, + ) + .await?; + let result = with_validated_platform_session_identity(&identity, || { + create_project_from_installed_template_at( + &projects_root, + Path::new(&record.project_dir), + name.as_deref(), + planning.unwrap_or(false), + ) + }); + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::Template, + Some(template_id), + ); + } + result +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index cea4b4352..5e9fbc633 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -1,7 +1,7 @@ use super::*; use base64::Engine as _; use serde_json::Value; -use sha2::{Digest as _, Sha256}; +use sha2::Sha256; use std::collections::{BTreeMap, BTreeSet}; use std::io::{self, Read, Write}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -392,64 +392,6 @@ pub(crate) fn freeze_test_root_goal_contract_at( .expect("freeze test root Goal Contract") } -pub(crate) fn pass_test_root_acceptance_graph_at( - root: &Path, - state: &AgentRuntimeState, -) -> AgentRuntimeAcceptanceGraphState { - let contract = read_game_creator_agent_runtime_goal_contract_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &state.run_id, - ) - .expect("read test root Goal Contract") - .unwrap_or_else(|| freeze_test_root_goal_contract_at(root, &state.run_id)); - let action = AgentRuntimeToolAction { - tool: "file.list".to_string(), - reason: Some("提供测试验收图的当前 revision 机器证据".to_string()), - input: serde_json::json!({ "path": "" }), - }; - let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task); - let action_id = - agent_runtime_tool_action_id(&state.run_id, u32::MAX - 1, 0, 0, &action_fingerprint); - let revision = read_game_creator_agent_runtime_project_revision(root) - .expect("read test project revision") - .revision; - append_agent_runtime_action_receipt_with_project_revision_before( - root, - state, - &action_id, - &action_fingerprint, - "file.list", - "auto", - None, - &AgentRuntimeToolObservation { - tool: "file.list".to_string(), - status: "ok".to_string(), - summary: "测试验收证据已持久化".to_string(), - detail: None, - }, - revision, - ) - .expect("append test acceptance receipt"); - update_game_creator_agent_runtime_acceptance_graph_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &state.run_id, - &contract.contract_fingerprint, - &[AgentRuntimeAcceptanceEvaluationDraft { - criterion_id: "test-acceptance".to_string(), - status: "passed".to_string(), - evidence: vec![AgentRuntimeAcceptanceEvidenceRef { - agent_id: state.agent_id.clone(), - run_id: state.run_id.clone(), - action_id, - }], - summary: "测试声明的验收条件已满足".to_string(), - }], - ) - .expect("pass test root Acceptance Graph") -} - pub(crate) fn canonical_test_tempdir(prefix: &str) -> tempfile::TempDir { let temp_root = std::env::temp_dir() .canonicalize() @@ -647,62 +589,6 @@ pub(crate) async fn wait_for_agent_runtime_terminal_and_lane_release_async( } } -pub(crate) async fn wait_for_agent_runtime_manifest_projection_async( - root: &Path, - agent_id: &str, - run_id: &str, - runtime_status: &str, - phase: &str, - manifest_status: GameCreationAppTaskStatus, -) -> AgentRuntimeResult { - let deadline = Instant::now() + AGENT_RUNTIME_TERMINAL_WAIT_BUDGET; - let mut terminal = wait_for_agent_runtime_terminal_and_lane_release_async( - root, - agent_id, - run_id, - runtime_status, - phase, - ) - .await; - let mut stable_samples = 0_u8; - loop { - if let Ok(project_lock) = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "test.wait_runtime_manifest_projection", - ) { - let manifest = read_manifest_for_project(root) - .expect("read manifest while waiting for terminal projection"); - let projected = manifest - .tasks - .iter() - .find(|task| task.id == agent_id) - .is_some_and(|task| task.status == manifest_status); - let reread = read_game_creator_agent_runtime_at(root, agent_id) - .expect("reread runtime while waiting for terminal projection"); - drop(project_lock); - if projected - && reread.state.run_id == run_id - && reread.state.status == runtime_status - && reread.state.phase == phase - { - stable_samples = stable_samples.saturating_add(1); - terminal = reread; - if stable_samples >= 2 { - return terminal; - } - } else { - stable_samples = 0; - terminal = reread; - } - } - assert!( - Instant::now() < deadline, - "runtime terminal manifest projection did not settle for {agent_id}/{run_id}" - ); - tokio::time::sleep(Duration::from_millis(20)).await; - } -} - async fn wait_for_agent_runtime_lane_release_async( root: &Path, agent_id: &str, @@ -3548,10 +3434,6 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( base_url } -fn spawn_mock_external_canvas_api_server() -> String { - spawn_mock_external_canvas_api_server_with_capture(3, None) -} - fn spawn_mock_external_canvas_generation_api_server( request_sender: Option>, ) -> String { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 36e81afb5..6e0b772d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -14,13 +14,12 @@ pub(super) use super::super::{ append_auto_tool_action_audit_pair_for_test, assert_auto_tool_action_audit_pair, assert_pending_runtime_decision_revalidates_after_lock, assert_task_status, fake_llm_game_draft, final_tool_plan_response, mock_http_request_json, - native_agent_tool_plan_chat_response, observe_agent_runtime_agent_delegate, - pending_tool_action_for_test, persist_needs_reconciliation_runtime_for_test, - persist_project_verification_for_test, read_agent_db_records_for_test, - register_canvas_visual_asset_fixture, spawn_barrier_mock_llm_server, - spawn_interruptible_mock_llm_server_with_capture, spawn_mock_llm_raw_responses_with_capture, - spawn_mock_llm_server, spawn_mock_llm_server_responses, - spawn_mock_llm_server_responses_with_capture, + native_agent_tool_plan_chat_response, pending_tool_action_for_test, + persist_needs_reconciliation_runtime_for_test, persist_project_verification_for_test, + read_agent_db_records_for_test, register_canvas_visual_asset_fixture, + spawn_barrier_mock_llm_server, spawn_interruptible_mock_llm_server_with_capture, + spawn_mock_llm_raw_responses_with_capture, spawn_mock_llm_server, + spawn_mock_llm_server_responses, spawn_mock_llm_server_responses_with_capture, spawn_releasable_mock_llm_server_responses_with_capture, spawn_releasable_mock_llm_server_responses_with_capture_at, start_agent_runtime_steer_fixture, ui_prototype_assessment_fixture, unique_project_path, use_test_runtime_config_dir, @@ -38,10 +37,9 @@ pub(super) use crate::{ agent_runtime_background_worker_threads_for_test, agent_runtime_executable_tools, agent_runtime_read_only_delivery_completion_plan_update, agent_runtime_run_profile_identity_at, agent_runtime_tool_action_fingerprint, agent_runtime_tool_action_id, - agent_runtime_tool_allowed_for_agent, agent_runtime_tool_policy_snapshot_for_run_at, + agent_runtime_tool_policy_snapshot_for_run_at, agent_runtime_tool_requires_pending_revision_gate, - agent_runtime_tool_requires_repository_context_fingerprint_gate, - agent_runtime_verified_delivery_completion_plan_update, append_agent_db_record, + agent_runtime_tool_requires_repository_context_fingerprint_gate, append_agent_db_record, append_agent_db_terminal_observation_if_missing_for_action, append_agent_runtime_action_receipt, append_agent_runtime_tool_call_record, append_game_creator_agent_runtime_action_event, append_game_creator_agent_runtime_task, @@ -67,8 +65,8 @@ pub(super) use crate::{ game_creator_agent_runtime_tool_policy_rule_for_run, init_local_game_project_at, invalidate_agent_runtime_project_verification_after_preview_failure_at, native_runtime_function_name, observe_agent_runtime_action_history, - observe_agent_runtime_agent_message, observe_agent_runtime_agent_spawn_isolated, - plan_game_creation_agent_pass, prepare_agent_runtime_project_mutation_locked, + observe_agent_runtime_agent_message, plan_game_creation_agent_pass, + prepare_agent_runtime_project_mutation_locked, prepare_game_creator_agent_runtime_provider_action_batch, project_verification_completion_blocker_at, read_all_game_creator_agent_runtime_tasks, read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_context_bundle, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs index 2e7f6fe79..79d99c905 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -18,15 +18,18 @@ use super::identity_order_validation::{ parse_tool_plan_base_request_slot, request_slot_for_attempt, }; use super::ledger::response_for_persistence; +#[cfg(unix)] +use super::model::TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY; use super::model::{ INVALID_THINKING_CLOSE_MARKER, INVALID_THINKING_OPEN_MARKER, TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES, TOOL_PLAN_HANDOFF_LABEL, TOOL_PLAN_HANDOFF_MAX_ENTRIES, - TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS, TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY, - TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES, TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, + TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS, TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES, + TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, }; +#[cfg(unix)] +use super::storage_common::serialize_ledger_for_storage; use super::storage_common::{ - path_key, response_fingerprint, serialize_ledger_for_storage, tool_plan_handoff_path, - tool_plan_handoff_relative_path, + path_key, response_fingerprint, tool_plan_handoff_path, tool_plan_handoff_relative_path, }; #[cfg(unix)] use super::storage_unix::{ @@ -43,7 +46,9 @@ use super::*; use crate::agent::{ agent_runtime_json_sidecar_backup_path, write_agent_runtime_json_sidecar_with_max_bytes, }; -use crate::provider_retry::{self, AgentRuntimeProviderRetryIdentity}; +#[cfg(unix)] +use crate::provider_retry; +use crate::provider_retry::AgentRuntimeProviderRetryIdentity; #[test] fn tool_plan_handoff_classifies_windows_handle_install_failure_as_storage() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs index 512165949..0303b0a14 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs @@ -2,7 +2,9 @@ pub mod recognition; pub mod separation; pub mod utils; +#[cfg(not(test))] +pub(crate) use recognition::recognize_ui_impl; +pub(crate) use recognition::recognize_ui_impl_with_provider; pub use recognition::RecognitionDTO; -pub(crate) use recognition::{recognize_ui_impl, recognize_ui_impl_with_provider}; pub(crate) use separation::separate_ui_impl; pub use separation::{SeparationDTO, SeparationRecoveryDTO}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs index 2e23b1aaa..3567bc732 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs @@ -828,6 +828,7 @@ pub(crate) async fn recognize_ui_impl_with_provider( Ok(RecognitionDTO { ui_trees }) } +#[cfg(not(test))] pub(crate) async fn recognize_ui_impl( project_path: String, state: State, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs index 595dee76e..37c486af7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs @@ -543,4 +543,78 @@ mod tests { assert_eq!(batch.len(), 1); assert_eq!(batch[0].id.as_str(), "oversized"); } + + #[tokio::test(flavor = "current_thread")] + async fn restored_completed_separation_keeps_bound_artifacts_without_regeneration() { + let project = tempfile::tempdir().expect("create project fixture"); + let source = project.path().join("page.png"); + image::RgbaImage::from_pixel(100, 100, image::Rgba([20, 30, 40, 255])) + .save(&source) + .expect("write source image"); + let source_before = std::fs::read(&source).expect("read source image"); + + let asset_id = "ui-design-1"; + let sidecar = separation_sidecar_dir(project.path(), asset_id).expect("sidecar path"); + std::fs::create_dir_all(&sidecar).expect("create sidecar"); + let cut = sidecar.join("cut-existing.png"); + image::RgbaImage::from_pixel(8, 8, image::Rgba([20, 30, 40, 255])) + .save(&cut) + .expect("write existing cut image"); + let cut_before = std::fs::read(&cut).expect("read existing cut image"); + + let image = Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + }); + let state = state(node("root", None, vec![node("image", Some(image), vec![])])); + let mut persisted = construct_separation_state(&state); + let node_id = NodeId::new("image").unwrap(); + assert_eq!( + next_image_batch(&persisted, &persisted.trees[0]) + .iter() + .map(|node| node.id.as_str()) + .collect::>(), + vec!["image"] + ); + persisted.bound.push(BoundNode { + node_id, + cut_image_path: project_relative_path(project.path(), &cut).unwrap(), + }); + + let state_path = separation_state_path(project.path(), asset_id).unwrap(); + write_separation_state(state_path.clone(), &persisted) + .await + .expect("persist completed separation"); + let sidecar_files = || { + let mut names = std::fs::read_dir(&sidecar) + .expect("read sidecar") + .map(|entry| entry.expect("sidecar entry").file_name()) + .collect::>(); + names.sort(); + names + }; + let files_before = sidecar_files(); + + let _session = crate::platform_session::install_test_platform_session( + "separation-user", + "fixture-token", + "http://127.0.0.1:9", + ); + let result = workflow::separate_ui_impl( + project.path().to_string_lossy().into_owned(), + asset_id.to_string(), + state, + ) + .await + .expect("restore completed separation"); + + assert_eq!(result.bound_nodes, persisted.bound); + assert!(result.problematic_nodes.is_empty()); + assert_eq!(read_separation_state(&state_path).unwrap(), persisted); + assert_eq!(std::fs::read(&source).unwrap(), source_before); + assert_eq!(std::fs::read(&cut).unwrap(), cut_before); + assert_eq!(sidecar_files(), files_before); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows.rs b/apps/ai-game-creator-shell/src-tauri/src/windows.rs index 4406f2e29..e64226359 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/windows.rs @@ -146,43 +146,8 @@ fn percent_encode_query_value(value: &str) -> String { encoded } -#[tauri::command] -pub(crate) fn open_game_creator_workspace_window( - app: tauri::AppHandle, - window: tauri::Window, - project_path: String, -) -> Result<(), String> { - let project_path = validate_workspace_window_project_path(&project_path)?; - if let Some(existing) = app.get_webview_window("main") { - existing.close().map_err(|error| error.to_string())?; - } - tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path)) - .title(crate::game_creator_product_name(&app)) - .decorations(false) - .inner_size(1180.0, 820.0) - .min_inner_size(760.0, 560.0) - .build() - .map_err(|error| error.to_string())?; - window.close().map_err(|error| error.to_string())?; - Ok(()) -} - -#[tauri::command] -pub(crate) fn open_game_creator_launcher_window( - app: tauri::AppHandle, - window: tauri::Window, -) -> Result<(), String> { - if let Some(existing) = app.get_webview_window("launcher") { - existing.set_focus().map_err(|error| error.to_string())?; - } else { - tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url()) - .title(crate::game_creator_product_name(&app)) - .decorations(false) - .inner_size(820.0, 640.0) - .min_inner_size(720.0, 520.0) - .build() - .map_err(|error| error.to_string())?; - } - window.close().map_err(|error| error.to_string())?; - Ok(()) -} +// Webview 接入独立于受测的窗口 URL/路径校验及后台进程配置。 +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/windows/desktop.rs new file mode 100644 index 000000000..2d4602d6e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/windows/desktop.rs @@ -0,0 +1,42 @@ +use super::*; + +#[tauri::command] +pub(crate) fn open_game_creator_workspace_window( + app: tauri::AppHandle, + window: tauri::Window, + project_path: String, +) -> Result<(), String> { + let project_path = validate_workspace_window_project_path(&project_path)?; + if let Some(existing) = app.get_webview_window("main") { + existing.close().map_err(|error| error.to_string())?; + } + tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path)) + .title(crate::game_creator_product_name(&app)) + .decorations(false) + .inner_size(1180.0, 820.0) + .min_inner_size(760.0, 560.0) + .build() + .map_err(|error| error.to_string())?; + window.close().map_err(|error| error.to_string())?; + Ok(()) +} + +#[tauri::command] +pub(crate) fn open_game_creator_launcher_window( + app: tauri::AppHandle, + window: tauri::Window, +) -> Result<(), String> { + if let Some(existing) = app.get_webview_window("launcher") { + existing.set_focus().map_err(|error| error.to_string())?; + } else { + tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url()) + .title(crate::game_creator_product_name(&app)) + .decorations(false) + .inner_size(820.0, 640.0) + .min_inner_size(720.0, 520.0) + .build() + .map_err(|error| error.to_string())?; + } + window.close().map_err(|error| error.to_string())?; + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index bed37e341..cf24da0c5 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -39,19 +39,14 @@ "vendor/codex-utils-path-uri/LICENSE": "licenses/codex-utils-path-uri/LICENSE", "vendor/codex-utils-path-uri/NOTICE": "licenses/codex-utils-path-uri/NOTICE", "vendor/codex-utils-absolute-path/LICENSE": "licenses/codex-utils-absolute-path/LICENSE", - "vendor/codex-utils-absolute-path/NOTICE": "licenses/codex-utils-absolute-path/NOTICE", - "resources/claude-agent": "claude-agent" + "vendor/codex-utils-absolute-path/NOTICE": "licenses/codex-utils-absolute-path/NOTICE" }, "linux": { "deb": { - "depends": [ - "bubblewrap" - ] + "depends": ["bubblewrap"] }, "rpm": { - "depends": [ - "bubblewrap" - ] + "depends": ["bubblewrap"] } }, "icon": [ diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json index 019a0f248..ef3f1170b 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json @@ -19,7 +19,8 @@ "resources/codex/mac-native/darwin-x64/codex-package.json": "coding-agent/mac-native/darwin-x64/codex-package.json", "resources/codex/mac-native/darwin-x64/NOTICE.md": "coding-agent/mac-native/darwin-x64/NOTICE.md", "resources/codex/mac-native/darwin-x64/manifest.json": "coding-agent/mac-native/darwin-x64/manifest.json", - "resources/plugins": "plugins" + "resources/plugins": "plugins", + "resources/claude-agent": "claude-agent" } }, "plugins": { diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json index 5fa3b1b35..9ef52cc2a 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json @@ -12,7 +12,8 @@ "resources/codex/win-x64/codex-package.json": "coding-agent/win-x64/codex-package.json", "resources/codex/win-x64/NOTICE.md": "coding-agent/win-x64/NOTICE.md", "resources/codex/win-x64/manifest.json": "coding-agent/win-x64/manifest.json", - "resources/plugins": "plugins" + "resources/plugins": "plugins", + "resources/claude-agent": "claude-agent" } } } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts b/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts index a166a5f8f..674160eb8 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts @@ -4,19 +4,66 @@ import type { HomeCreationType } from '../../view/home/useHomeDraftStore'; export const HOME_WEB_PREFLIGHT_FAILURE = 'Web 游戏环境预检未通过,请检查 Node/npm 或浏览器后重试;尚未开始生成。'; +export const HOME_WEB_PREFLIGHT_IPC_FAILURE = + 'Web 游戏环境预检调用失败,客户端连接异常;请重启客户端后重试,尚未开始生成。'; + +type HomeWebPreflightReport = { + status?: string; + code?: string; +}; + +function knownPreflightCode(value: unknown): string | undefined { + const text = value instanceof Error ? value.message : value; + if (typeof text !== 'string') return undefined; + const match = text.match( + /(?:预检未通过:|预检未通过(|code[=:])([a-z0-9-]+)/i, + ); + return match?.[1]; +} + +export function homeWebPreflightErrorMessage(value: unknown): string { + if (value instanceof Error && value.message === HOME_WEB_PREFLIGHT_FAILURE) { + return HOME_WEB_PREFLIGHT_FAILURE; + } + const code = knownPreflightCode(value); + if (code) return `Web 游戏环境预检未通过(${code});尚未开始生成。`; + return HOME_WEB_PREFLIGHT_IPC_FAILURE; +} + +function isHostPreflightError(value: unknown): boolean { + if (!(value instanceof Error)) return false; + return ( + value.message === HOME_WEB_PREFLIGHT_FAILURE || + Boolean(knownPreflightCode(value.message)) + ); +} + export async function ensureHomeWebCreationEnvironment( invoke: TauriInvoke, creationType: HomeCreationType, startMode: ProjectStartMode, ): Promise { if (creationType !== 'game' || startMode !== 'direct-build') return; - try { - const report = await invoke<{ status: string }>( - 'preflight_web_game_creation', - ); - if (report?.status !== 'ready') throw new Error(HOME_WEB_PREFLIGHT_FAILURE); - } catch { - // 不把子进程、Provider 或宿主路径的原始错误注入首页状态栏。 - throw new Error(HOME_WEB_PREFLIGHT_FAILURE); + let lastError: unknown; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const report = await invoke( + 'preflight_web_game_creation', + ); + if (report?.status === 'ready') return; + throw new Error( + report?.code + ? `Web 游戏环境预检未通过:${report.code}` + : HOME_WEB_PREFLIGHT_FAILURE, + ); + } catch (error) { + lastError = error; + const code = knownPreflightCode(error); + if (isHostPreflightError(error) || code || attempt === 1) { + throw new Error(homeWebPreflightErrorMessage(error)); + } + await new Promise((resolve) => window.setTimeout(resolve, 250)); + } } + throw new Error(homeWebPreflightErrorMessage(lastError)); } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index c7a572afc..bd065b4fa 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -57,7 +57,7 @@ import { importDesignFiles } from '../project-workspace/importDesignFiles'; import { clearAclElevationDenials } from './aclElevation'; import { ensureHomeWebCreationEnvironment, - HOME_WEB_PREFLIGHT_FAILURE, + homeWebPreflightErrorMessage, } from './homeWebPreflight'; import type { ShowTransientNotice } from './model'; import { resolveSessionPreviewOnProjectOpen } from './sessionPreview'; @@ -458,9 +458,10 @@ export function useHomeProjectCreation({ } try { await ensureHomeWebCreationEnvironment(invoke, creationType, startMode); - } catch { - setStatus(HOME_WEB_PREFLIGHT_FAILURE); - return HOME_WEB_PREFLIGHT_FAILURE; + } catch (error) { + const message = homeWebPreflightErrorMessage(error); + setStatus(message); + return message; } const result = await invoke( 'init_local_game_project', @@ -894,7 +895,8 @@ export function useHomeProjectCreation({ draft.creationType, startMode, ); - } catch { + } catch (error) { + const message = homeWebPreflightErrorMessage(error); setHomeCreationOperation( transitionClientOperation( operation, @@ -902,7 +904,7 @@ export function useHomeProjectCreation({ operationScope(), ), ); - return HOME_WEB_PREFLIGHT_FAILURE; + return message; } const suggestedName = options.suggestName ? await suggestAutomaticProjectName(invoke, draft) diff --git a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx index ad93e007a..e24a39d30 100644 --- a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx +++ b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx @@ -3,7 +3,10 @@ import { act, cleanup, renderHook } from '@testing-library/react'; import { afterEach, expect, it, vi } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; -import { HOME_WEB_PREFLIGHT_FAILURE } from '../src/features/app-shell/homeWebPreflight'; +import { + HOME_WEB_PREFLIGHT_FAILURE, + HOME_WEB_PREFLIGHT_IPC_FAILURE, +} from '../src/features/app-shell/homeWebPreflight'; import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation'; afterEach(() => { @@ -67,26 +70,58 @@ it('awaits the real home preflight before naming or creating a game', async () = expect(result.current.currentProjectContext?.creationType).toBe('game'); }); -it.each([false, true])( - 'blocks naming, creation, and first generation when preflight fails (throws=%s)', - async (throws) => { - const { result, calls } = mount(async () => { - if (throws) throw new Error('private host path'); - return { status: 'blocked' }; - }); - let status = ''; - await act(async () => { - status = await result.current.createHomeDraftAutomatically( - { creationType: 'game', prompt: '做一个小游戏', attachments: [] }, - 'direct-build', - ); - }); - expect(status).toBe(HOME_WEB_PREFLIGHT_FAILURE); - expect(calls).toEqual(['preflight_web_game_creation']); - expect(result.current.currentProjectContext).toBeNull(); - expect(status).not.toContain('private'); - }, -); +it('blocks naming, creation, and first generation with the host error code', async () => { + const { result, calls } = mount(async () => { + throw new Error( + 'Web 游戏环境预检未通过:web-preflight-browser-failed;尚未启动生成', + ); + }); + let status = ''; + await act(async () => { + status = await result.current.createHomeDraftAutomatically( + { creationType: 'game', prompt: '做一个小游戏', attachments: [] }, + 'direct-build', + ); + }); + expect(status).toBe( + 'Web 游戏环境预检未通过(web-preflight-browser-failed);尚未开始生成。', + ); + expect(calls).toEqual(['preflight_web_game_creation']); + expect(result.current.currentProjectContext).toBeNull(); +}); + +it('distinguishes an IPC failure from a host preflight failure', async () => { + const { result, calls } = mount(async () => { + throw new Error('Failed to fetch'); + }); + let status = ''; + await act(async () => { + status = await result.current.createHomeDraftAutomatically( + { creationType: 'game', prompt: '做一个小游戏', attachments: [] }, + 'direct-build', + ); + }); + expect(status).toBe(HOME_WEB_PREFLIGHT_IPC_FAILURE); + expect(calls).toEqual([ + 'preflight_web_game_creation', + 'preflight_web_game_creation', + ]); + expect(result.current.currentProjectContext).toBeNull(); +}); + +it('keeps the legacy fallback for a blocked report without a code', async () => { + const { result, calls } = mount(async () => ({ status: 'blocked' })); + let status = ''; + await act(async () => { + status = await result.current.createHomeDraftAutomatically( + { creationType: 'game', prompt: '做一个小游戏', attachments: [] }, + 'direct-build', + ); + }); + expect(status).toBe(HOME_WEB_PREFLIGHT_FAILURE); + expect(calls).toEqual(['preflight_web_game_creation']); + expect(result.current.currentProjectContext).toBeNull(); +}); it.each(['game', 'doc'] as const)( 'does not require the Web preflight for %s planning', diff --git a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts index 90f8e6750..558a8a6a4 100644 --- a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts +++ b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts @@ -171,8 +171,8 @@ describe('进入项目时的预览活体核验', () => { }); describe('本地预览记录的生命周期(Rust 侧结构性守卫)', () => { - const mainSource = readFileSync( - repoPath('apps/ai-game-creator-shell/src-tauri/src/main.rs'), + const desktopSource = readFileSync( + repoPath('apps/ai-game-creator-shell/src-tauri/src/desktop.rs'), 'utf8', ); const previewSource = readFileSync( @@ -186,7 +186,7 @@ describe('本地预览记录的生命周期(Rust 侧结构性守卫)', () => // 打架),所以按本仓既有做法直接钉源码结构。 const exitBranch = /matches!\(event, tauri::RunEvent::Exit\)\s*\{([\s\S]*?)\n {8}\}/u.exec( - mainSource, + desktopSource, )?.[1] ?? ''; expect(exitBranch).toContain('preview::stop_local_game_preview_on_exit'); }); diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts index 7b8308410..b3de87823 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -5,6 +5,10 @@ import { join } from 'node:path'; import { describe, expect, test, vi } from 'vitest'; +import { + readCargoTarget, + resolveEditorFeatures, +} from '../scripts/cargo-features.mjs'; import { isProcessGroupAlive, spawnChild, @@ -12,6 +16,8 @@ import { } from '../scripts/start-dev-stack.mjs'; import { buildTauriArguments, + buildTauriDevProcessEnv, + prepareBundledResourcesBeforeTauri, runTauriDev as runTauriDevImpl, withDevCargoFeatures, } from '../scripts/start-tauri-dev.mjs'; @@ -27,7 +33,13 @@ const resolveTestEndpoint = async () => testEndpoint; const runTauriDev = ( argv: string[], options: Parameters[1], -) => runTauriDevImpl(argv, { prepareFrontend: async () => {}, ...options }); +) => + runTauriDevImpl(argv, { + prepareFrontend: async () => {}, + // 随包资源准备会读取真实上游包与仓库插件工作区;需要断言的用例自行注入。 + prepareResources: () => {}, + ...options, + }); async function waitForFile(path: string, timeoutMs = 5000) { const deadline = Date.now() + timeoutMs; @@ -152,6 +164,10 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { prepareFrontend: async () => { order.push('frontend-ready'); }, + prepareResources: (features) => { + expect(Array.isArray(features)).toBe(true); + order.push('resources'); + }, spawnCli: () => { order.push('spawn'); return child; @@ -170,6 +186,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { expect(result).toBe(1); expect(order).toEqual([ 'preflight', + 'resources', 'frontend-ready', 'spawn', 'exit', @@ -300,3 +317,311 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { } }); }); + +describe('AI 游戏创作 Tauri dev 进程环境', () => { + const posixTest = process.platform === 'win32' ? test.skip : test; + + // 本机 `~/.cargo/config.toml` 或仓库级 Cargo 配置里的 sccache wrapper 只有在环境变量 + // 非空时才会被覆盖;这里必须显式写入要交给 Tauri Cargo 的 wrapper 决策结果。 + posixTest('本地 dev 不把 sccache 交给 Tauri Cargo', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const env = buildTauriDevProcessEnv(testEndpoint, { + RUSTC_WRAPPER: 'sccache', + CARGO_BUILD_RUSTC_WRAPPER: 'sccache', + }); + + expect(env.RUSTC_WRAPPER).toBe('/usr/bin/env'); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('/usr/bin/env'); + } finally { + warn.mockRestore(); + } + }); + + posixTest('未显式配置 wrapper 时清空两个变量', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const env = buildTauriDevProcessEnv(testEndpoint, { + CARGO_TERM_COLOR: 'never', + }); + + expect(env.RUSTC_WRAPPER).toBe(''); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(''); + expect(env.CARGO_TERM_COLOR).toBe('never'); + } finally { + warn.mockRestore(); + } + }); + + posixTest('保留显式自定义 wrapper 且不改写调用方 env', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const input = { RUSTC_WRAPPER: '/opt/custom/rustc-wrapper' }; + const env = buildTauriDevProcessEnv(testEndpoint, input); + + expect(env.RUSTC_WRAPPER).toBe('/opt/custom/rustc-wrapper'); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('/opt/custom/rustc-wrapper'); + expect(env.GENARRATIVE_AGC_VITE_PORT).toBe(String(testEndpoint.port)); + expect(input).toEqual({ RUSTC_WRAPPER: '/opt/custom/rustc-wrapper' }); + } finally { + warn.mockRestore(); + } + }); +}); + +describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { + const windowsFeatures = [ + 'cocos-editor-execute', + 'unity-editor-execute', + 'godot-editor-execute', + ]; + // buildTauriArguments 注入的动态 devUrl 配置:最终交给 Tauri 的参数里长这样。 + const devConfig = + '{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}'; + + test('显式 --target 决定 staging 目标,未指定或属于应用参数时回落宿主目标', () => { + const calls: [string, string[]][] = []; + const hostTarget = 'aarch64-apple-darwin'; + const prepare = (options: { + target: string; + features: Iterable; + }) => { + calls.push([options.target, [...options.features]]); + return []; + }; + + for (const argv of [ + ['dev', '--config', devConfig], + ['dev', '--target', 'x86_64-apple-darwin', '--config', devConfig], + ['dev', '--target=x86_64-pc-windows-msvc', '--config', devConfig], + // 启动器把 `--` 之后的参数交给应用(补成第二段分隔符):不能拿它改随包资源目标。 + [ + 'dev', + '--config', + devConfig, + '--', + '--', + '--target=x86_64-pc-windows-msvc', + ], + ]) { + prepareBundledResourcesBeforeTauri(windowsFeatures, argv, { + hostTarget, + prepare, + log: () => {}, + }); + } + + expect(calls.map(([target]) => target)).toEqual([ + hostTarget, + 'x86_64-apple-darwin', + 'x86_64-pc-windows-msvc', + hostTarget, + ]); + expect(calls[0][1]).toEqual(windowsFeatures); + }); + + test('宿主平台不受声明覆盖时跳过准备,且不为其新增随包支持', () => { + const prepare = vi.fn(); + const log = vi.fn(); + + prepareBundledResourcesBeforeTauri( + windowsFeatures, + ['dev', '--config', devConfig], + { hostTarget: null, prepare, log }, + ); + + expect(prepare).not.toHaveBeenCalled(); + expect(log.mock.calls.flat().join('\n')).toContain('跳过随包资源准备'); + }); + + async function runCapturingDev(argv: string[]) { + const prepared: unknown[][] = []; + const spawned: string[][] = []; + const child = Object.assign(new EventEmitter(), { + pid: 1234, + exitCode: 0, + signalCode: null, + kill: vi.fn(), + }); + const result = await runTauriDev(argv, { + resolveDevEndpoint: resolveTestEndpoint, + preflight: async () => {}, + prepareResources: (...args: unknown[]) => { + prepared.push(args); + }, + spawnCli: (args: string[]) => { + spawned.push(args); + return child; + }, + waitForCli: async () => ({ type: 'exit', code: 0, signal: null }), + terminateTree: async () => ({ stopped: true, forced: false }), + }); + return { result, prepared, spawned }; + } + + test('显式 -f 优先于环境变量,并同时决定随包资源 feature 与 cargo 参数', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', 'template-library-fixtures'); + try { + const { result, prepared, spawned } = await runCapturingDev([ + '-f', + 'custom-feature', + ]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([ + ['custom-feature'], + ]); + expect(spawned[0].slice(0, 3)).toEqual(['dev', '-f', 'custom-feature']); + expect(spawned[0]).not.toContain( + `--features=${windowsFeatures.join(',')}`, + ); + // 随包资源准备拿到的是最终交给 Tauri 的参数,不是启动器收到的原始参数。 + expect(prepared[0][1]).toEqual(spawned[0]); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('显式 --target 同时进入随包资源准备与 Tauri 参数', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', ''); + try { + const { result, prepared, spawned } = await runCapturingDev([ + '--target', + 'x86_64-apple-darwin', + ]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([[]]); + expect(prepared[0][1]).toEqual([ + 'dev', + '--target', + 'x86_64-apple-darwin', + '--config', + devConfig, + ]); + expect(spawned[0]).toEqual(prepared[0][1]); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('启动器 `--` 之后的应用参数不参与 staging feature 解析', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', ''); + try { + const { result, prepared, spawned } = await runCapturingDev([ + '--', + '--features=app-tool', + ]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([[]]); + expect(spawned[0]).toEqual([ + 'dev', + '--config', + devConfig, + '--', + '--', + '--features=app-tool', + ]); + expect(prepared[0][1]).toEqual(spawned[0]); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('空串 AGC_DEV_CARGO_FEATURES 关闭默认 feature,staging 与 cargo 都不带 feature', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', ''); + try { + const { result, prepared, spawned } = await runCapturingDev([]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([[]]); + expect( + spawned[0].some((argument) => argument.startsWith('--features')), + ).toBe(false); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('环境变量只在不与显式 CLI 冲突时生效', () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', 'template-library-fixtures'); + try { + expect( + resolveEditorFeatures({ + argv: ['dev', '--config', devConfig], + target: 'win32', + }), + ).toEqual(['template-library-fixtures']); + expect( + resolveEditorFeatures({ + argv: ['dev', '-f', 'cli-feature', '--config', devConfig], + target: 'win32', + }), + ).toEqual(['cli-feature']); + } finally { + vi.unstubAllEnvs(); + } + }); +}); + +describe('AI 游戏创作 Tauri dev 目标与 feature 解析', () => { + test('目标只从构建参数区解析,取值缺失、取到选项或重复都失败关闭', () => { + expect(readCargoTarget([])).toBeUndefined(); + expect(readCargoTarget(['--no-watch'])).toBeUndefined(); + expect(readCargoTarget(['-t', 'x86_64-apple-darwin'])).toBe( + 'x86_64-apple-darwin', + ); + expect(readCargoTarget(['--target=x86_64-apple-darwin'])).toBe( + 'x86_64-apple-darwin', + ); + // runner 参数由 Tauri 追加给 cargo:仍然算构建目标。 + expect( + readCargoTarget(['build', '--', '--target=aarch64-apple-darwin']), + ).toBe('aarch64-apple-darwin'); + // 第二个 `--` 之后是应用参数。 + expect( + readCargoTarget(['dev', '--', '--', '--target=aarch64-apple-darwin']), + ).toBeUndefined(); + expect(() => readCargoTarget(['--target'])).toThrow('缺少有效目标'); + expect(() => readCargoTarget(['--target', '--no-watch'])).toThrow( + '缺少有效目标', + ); + expect(() => readCargoTarget(['--target', 'a', '-t', 'b'])).toThrow( + '不能重复指定', + ); + }); + + test('feature 解析覆盖 -f 拼写、合并多个 flag 并止步于应用参数', () => { + const cases: [string[], string[]][] = [ + [ + ['-f', 'a,b'], + ['a', 'b'], + ], + [['-fa'], ['a']], + [['--features=explicit'], ['explicit']], + // Cargo 会合并多个 feature flag:staging 必须拿到同一集合。 + [ + ['--features', 'a', '-f', 'b'], + ['a', 'b'], + ], + // 显式给空集合即关闭默认,而不是回落默认值。 + [['--features='], []], + // 第二个 `--` 之后是应用参数。 + [ + ['dev', '--config', '{}', '--', '--', '-f', 'app-feature'], + [ + 'cocos-editor-execute', + 'unity-editor-execute', + 'godot-editor-execute', + ], + ], + ]; + + for (const [argv, expected] of cases) { + expect(resolveEditorFeatures({ argv, target: 'win32', env: {} })).toEqual( + expected, + ); + } + }); +}); diff --git a/docs/README.md b/docs/README.md index cc3979acf..cca80fe09 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,6 +38,7 @@ - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 - [AGC 后端框架整理与演进路线](./technical/【技术方案】AGC后端框架整理与演进路线-2026-09-18.md):共享 Runtime、本地执行宿主、云端控制面、领域/平台适配器及分阶段收口边界。 +- [AGC 随包资源 staging 归位](./technical/【技术方案】AGC随包资源staging归位-2026-09-26.md):随包资源改由准备步骤在 `tauri dev|build` 之前一次性生成、`build.rs` 退化为校验者;含缓存与原子性合同、入口接线、验收判据与里程碑拆分。 - [AGC 异步操作可恢复闭环](./【技术方案】AGC异步操作可恢复闭环-2026-09-14.md):认证响应体、最近项目检查和首页自动创建的超时、逐项恢复与跨页防重合同。 - [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。 - [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):历史方案,仅用于追溯 V2 的实现与退役过程,不作为当前实现依据。 diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index f07dd61d6..8781afa25 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -10,7 +10,7 @@ ## 背景 -AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(实时)、`turn-stream.jsonl`(文本段与工具交替顺序)、`tool-calls.jsonl`(已脱敏工具卡片),重进页面时还要额外接管活动回合快照。同一段文本和同一张工具卡片因此存在多个来源,实时与回读会互相覆盖,恢复路径也只能靠"哪个源先到"决定。 +AGC 项目开发聊天框当前同时从多处取数据:Direct 回合事件(实时)、另一份按顺序重建的回合投影、以及按读取期另算的工具卡片,重进页面时还要额外接管活动回合快照。同一段文本和同一张工具卡片因此存在多个来源,实时与回读会互相覆盖,恢复路径也只能靠"哪个源先到"决定。 `.agent/conversations/project.jsonl` 里的 Codex 原始条目本身已经带着顺序(`function_call` 与 `function_call_output` 按写入顺序落行),顺序信息并不是协议缺陷,而是在投影层被丢弃。 @@ -18,10 +18,10 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - AGC 项目开发对话的持久事实源只有 **项目对话历史**(`.agent/conversations/project.jsonl` 的原始条目);消息文本、工具卡片和它们的先后顺序都从它派生。 - 运行期间的回合状态只来自 **运行态事件**(Thread Manager 的 subscribe / consume / notify);`notify` 只做唤醒,不携带状态。 -- **聊天投影** 在读取与渲染时生成,不落盘、不成为第二事实源;DirectProject 聊天框停止读取 `turn-stream.jsonl` 与 `tool-calls.jsonl`,也不再提供供前端读取的命令。DirectRuntime 自己那套进度事件与文件写入属于运行时账本,本轮保留不动。 +- **聊天投影** 在读取与渲染时生成,不落盘、不成为第二事实源;聊天视图只由项目对话历史与运行态事件驱动,不再有供前端读取的旁路命令。 - 页面重进的运行态只由 `subscribe` 的 bootstrap 事件重建,删除活动回合快照接管路径。 - 可见性判断留在前端聊天投影:后端历史分页只按原始条目切片,前端自己跳过不可显示条目并推进锚点。 -- 线上模型是 **ts-rs 导出的 tagged enum**(`agent/direct_thread_wire.rs`),不是"一个大结构体加一堆可空字段":`DirectThreadItem` 用 `itemType` 区分条目,`DirectThreadEvent` 用 `type` 区分事件,前端直接消费生成的 TS 类型(改完 Rust 模型跑 `cargo test export_bindings`)。条目上的毫秒时间戳标 `#[ts(as = "f64")]`,因为 ts-rs 默认把 `u64` 映射成 `bigint`,而 Tauri 的 JSON 通道传的是 `number`。 +- 线上模型是 **ts-rs 导出的 tagged enum**(`agent/thread_manager/wire.rs`),不是"一个大结构体加一堆可空字段":`ThreadItem` 用 `itemType` 区分条目,`ThreadEvent` 用 `type` 区分事件,前端直接消费生成的 TS 类型(改完 Rust 模型跑 `cargo test export_bindings`)。条目上的毫秒时间戳标 `#[ts(as = "f64")]`,因为 ts-rs 默认把 `u64` 映射成 `bigint`,而 Tauri 的 JSON 通道传的是 `number`。 - 运行态事件与历史切片使用同形条目,Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「原始条目 → 视图」投影函数。 - 两侧的过滤口径必须完全一致,包含「哪些条目根本不是本项目的聊天条目」:Codex app-server 回显的用户消息(`userMessage` / 非 AGC 的 `role=user`)在落盘侧被过滤,在运行态事件侧也必须被过滤(`direct_thread_visible_item`)。少一侧就会出现「实时比历史多出两条同文本用户条目、各自开出一个耗时 0 秒的假回合,重进页面又正常」这类只有其中一侧的事实源缺陷。 - 搬运层不生成展示形状:Thread Manager 只下发脱敏原始条目(`itemType` 原样透传),工具卡片的 `kind`、标题、折叠摘要都由前端生成。 @@ -43,7 +43,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 思考正文以 `item.delta{kind:"reasoning"}` 流式下发(`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta`)。这不放宽可见范围:同一段文本本来就已落进 `project.jsonl` 并在 `item.completed` 展示;plan 文本与命令输出仍只降级为活动状态。 - 首屏历史由 `subscribe` 返回的 `lastCompletedItemId` 锚定,再取最近切片;删除返回整份对话的历史命令。锚点是切片**新端(较新一侧)的边界且含该条**(命令参数 `throughItemId`):比锚点更新的条目只从运行态事件来,历史切片与实时流因此不重叠;向后翻页仍用切片返回的 `firstItemId` 作为 `beforeItemId`(不含锚点)。订阅回执到达之前不读首屏,也不退化成"取文件尾"(那会把回执之后才完成的条目也拉进历史)。 - 生命周期锚点独立于 replay 队列保存(队列会回收 `cleanable` 事件,新订阅的游标又在队尾,回收后无法反推"最新回合是 started 还是 completed"),`subscribe` 必须返回最新的一条 `turn.started` / `turn.completed`,否则新订阅无法判定回合是否仍在运行。 -- 前端工具卡片形状是 `Omit`:聊天卡片不再有回合身份,`tool-calls.jsonl` 的持久化形状仍保留 `turnId`(DirectRuntime 的账本没动)。 +- 前端工具卡片形状是 `Omit`:聊天卡片不再有回合身份。 - 未识别 item 类型由 Rust 原样透传(只带类型与身份,Rust 侧留 TODO),当前由前端投影丢弃:哪些类型可见属于前端决策,不回 Rust 加白名单。 - 删除范围包含前端对 `read_direct_turn_stream`、`read_direct_tool_calls`、`read_direct_project_history`(整份历史)与 `game-creator-direct-turn-update` 事件的调用;保留分页用的历史切片读取(`read_direct_project_history_slice`),且该切片从文件尾反向扫描。`list_game_creator_direct_active_turns` 有意保留:它服务首页跨页面的「运行中的项目」列表,不是聊天框读路径。 - 前端删掉 `directTurnStream` / `directToolCalls` / 活动回合快照接管 / 瞬时应答文本这些并行状态,聊天视图只由 reducer 状态投影(含工具卡片)。 @@ -53,13 +53,12 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( ## 备选方案与取舍 -1. **保留 `tool-calls.jsonl` 作为"读侧已脱敏"缓存**:省一次脱敏与截断,但它成为与项目对话历史并行的第二事实源,卡片状态与顺序会和实时事件分叉。选择按读取期投影,必要时在进程内缓存。 +1. **保留一份"读侧已脱敏"的持久缓存**:省一次脱敏与截断,但它会成为与项目对话历史并行的第二事实源,卡片状态与顺序会和实时事件分叉。选择按读取期投影,必要时在进程内缓存。 2. **保留 Direct 回合事件作为实时传输**:迁移量小,但同一段文本仍有两条实时链路,reducer 必须处理互相覆盖,正是本次要消除的问题。 3. **让后端分页按"可显示消息数"切片**:界面能少写循环,代价是 Rust 需要理解 UI 可见性,界面规则一变就要同步改后端。 ## 影响 -- 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。 - 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。 - 回合结束语义务必由 `turn.completed` 判定(失败时同一事件带 `failure` 载荷,不新增事件类型);缺少该事件的残留回合不得被渲染成运行中。 - 两条已知边界,都**不**在本次补路径,且已被 [`【ADR】DirectProject命令接单化-2026-09-23`](./【ADR】DirectProject命令接单化-2026-09-23.md) 取代(§3、§2):① 宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,但队列随进程消失,新进程的订阅 bootstrap 因此不会看到"有开始没结束",界面不会卡在忙碌态;② `turn.started` 之前的失败按发生位置分流——接单**之前**的是拒单,根本不产生回合(不写用户条目、不写失败诊断),接单**之后**的由这一轮的占用对象统一收口成 `turn.completed`,不存在"有回合却没有事件解释"的路径。 @@ -69,5 +68,5 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - **调用级拒绝**(同一 `clientTurnId` 并发复用 / 项目已有另一条回合在跑 / 权限策略拒绝 / 目录锚不定 / 输入校验 / 环境与凭据未就绪)不属于回合失败:这一轮没有开始,只把原因回给命令边界(界面出运行错误横幅),不写失败诊断、不发 `failed` 事件、不进交付报告。此前它们与回合失败混在同一层、共用同一份错误文本,现在分流只认 typed 判据。 - 「活动回合的唯一判据」约束的是**原生回合**:界面上的「本地已发出、原生还没认领」是投影的展示态(`DirectChatTurn.state = 'awaiting-start'`),由本地在途用户条目身份派生,不构成第二套原生生命周期,也不参与 `turnRunning` 的判定。 - 三层数据流、变量归属与一次发送的时序写在代码里:`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表在 `apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。改判据时同步这两处与对应测试。 -- 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`。 +- 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致。 - id 空间已用源码核对:codex-rs `app-server-protocol/src/protocol/thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`,而 `project.jsonl` 落盘的是原始 response item。真实 app-server 会话核对仍列为运行时验收项。 diff --git a/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md new file mode 100644 index 000000000..a200761ec --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md @@ -0,0 +1,64 @@ +# 【实施计划】AGC 随包资源改由校验器读入 + +| 字段 | 值 | +| --------- | --------------------------------------------------------------- | +| Milestone | `docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md` | +| Status | ready(待里程碑规范评审通过后开工) | +| Owner | suzmii / Agent | + +## 修改边界 + +允许修改: + +- `apps/ai-game-creator-shell/src-tauri/build.rs`:新增只读校验调用点;本里程碑内保持现有写入分支不变(不改变既有构建行为)。 +- `apps/ai-game-creator-shell/src-tauri/build_support/**`:把平台布局、组件白名单、摘要校验整理为可被构建脚本之外的独立工具复用的一处声明。 +- 新增随包资源准备工具及其测试(位置见「待确认决策」)。 +- 需要时扩展 `apps/ai-game-creator-shell/scripts/check-config.mjs` 的断言。 +- 文档:主规范未决问题收口、开发运维文档对应段落。 + +明确不修改: + +- 三份 tauri 配置的 `resources` 映射、包内路径与安装包形态。 +- 运行时资源解析与完整性校验(`codex_cli.rs`、`plugin_host.rs`、`editor_adapters.rs`、`environment_check.rs`)。 +- 发布脚本流程、版本号机制、签名与上传。 +- dev 启动器与发布入口的接线(下一里程碑)。 +- 编辑器分支产物(Unity/Godot/Cocos)的生成方式(最后一个里程碑)。 + +## 实现顺序 + +1. **共用能力可复用**:确认 `build_support` 内的平台布局与组件白名单能被独立工具引用(现状先例:`src/agent/codex_cli.rs` 与 `main.rs` 已通过 `#[path]` 复用同一模块),把「布局 + 白名单 + 摘要校验」收敛为单一入口,避免准备工具另写一份清单。 +2. **准备工具骨架**:目标目录与清单写出、缓存 key(上游 lockfile 的 `resolved` + `integrity` + 布局版本 + 目标三元)、临时目录 + 原子替换、所有权与符号链接校验、并发串行化、单行汇总日志。先实现纯复制两条路径(随包组件、插件工作区),编辑器分支产物本轮仍由构建脚本生成。 +3. **幂等与失败关闭**:重复执行不改变内容与时间戳;上游缺失、摘要不匹配、目录被非本工具占用、目标平台不支持四类场景各自失败并给出可定位原因。 +4. **校验路径上线**:构建脚本在既有产物上执行只读校验(默认不影响现有写入行为),校验失败以明确原因中止。 +5. **测试与证据**:按里程碑「证据要求」补齐用例与运行记录。 + +## 验证命令 + +1. 声明唯一性与门禁:`npm run agc:bundled-resources:check`(已进 `agc:typecheck` 链),不一致时用 `npm run agc:bundled-resources:sync` 重新生成。 +2. 准备工具用例(含幂等与失败关闭):`npm run agc:bundled-resources:test`。 +3. 校验路径独立运行(跳过写入分支):`AGC_SKIP_RESOURCE_STAGING=1 cargo check --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`。 +4. Rust 用例:`cargo test --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml package_layout` 与 `cargo test --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml codex_bundle`。 +5. 幂等(真实工作区):连续两次 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`,第二次必须全部「命中缓存」,且两次之后的目录快照(相对路径、大小、mtime、sha256)完全一致。 +6. 并存一致:准备步骤产物与构建脚本产物逐文件比对(相对路径、大小、sha256)一致。 +7. 行为不回归:`cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features`(本里程碑不承诺构建变快,仅确认行为与改造前一致,并记录当前构建耗时作为后续里程碑基线)。 +8. 门禁:`node apps/ai-game-creator-shell/scripts/check-config.mjs`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`,以及改动范围内相关 vitest/Rust 测试。 + +## 风险与回滚点 + +| 风险 | 影响 | 处理 | +| --- | --- | --- | +| 校验器误判把构建卡死 | 影响所有本机构建 | 只读校验先以「不影响写入行为」的方式接入;出现误判可先关闭校验调用点回滚 | +| 准备工具与构建脚本并存产生双写 | 两处结果漂移、时间戳变化 | 并存期以「准备工具产物 == 构建脚本产物」逐文件比对作为过渡判据;不一致视为失败 | +| 缓存 key 漏掉上游变化 | 静默用旧组件 | key 含 lockfile `resolved` + `integrity` + 布局版本 + 三元;清单校验作为第二道闸 | +| 准备工具实现形态选错 | 返工 | 见「待确认决策」,评审时一次定清 | + +回滚点:本里程碑不改变既有构建行为,回滚只需移除校验调用点与准备工具,不影响产物与发布流程。 + +## 已定决策 + +准备工具的实现形态(主规范未决问题 1)**已定为混合**(2026-09-27,机制见主规范 §4.8): + +- 上游获取、`integrity` 校验、归档安全与原子替换复用 Node 侧既有范式(`scripts/stage-node-runtime.mjs`、`scripts/prepare-macos-codex.mjs`); +- 平台布局、组件白名单与逐文件摘要校验复用 Rust 侧既有声明(`build_support/codex_bundle.rs`、`build_support/godot_bundle.rs`),由准备工具与校验路径共用同一份声明文件承载,不再各写一份清单。(上游原生包元数据的期望值后来并入同一份声明;`build_support/codex_package_metadata.rs` 已在 M2 因失去调用方删除。) + +理由:避免出现第二份组件白名单,同时不必重写 registry 下载、`integrity` 与 tar 安全校验;缺点是声明需要经过一次生成步骤才能在 Rust 侧使用,由 `check-package-layout.mjs` 门禁保证两者一致。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md index 50a4811ea..a7948a8a4 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -8,20 +8,20 @@ ## 修改边界 -- 允许修改:`agent/direct_thread_wire.rs`、`agent/direct_thread_manager.rs`、`agent/codex_app_server/`、`agent/direct_project_history.rs`、`main.rs` 命令注册、`src/features/project-workspace/generated/`(ts-rs 生成目录)、AGC 前端订阅与聊天投影、对应测试与 `docs/`。 -- 明确不修改:SpacetimeDB schema 与绑定、HTTP/OpenAPI、DirectRuntime 自己的进度事件与 `turn-stream.jsonl` / `tool-calls.jsonl` 写入、Codex durable thread 行为、审批弹层现有状态来源。 +- 允许修改:`agent/thread_manager/wire.rs`、`agent/thread_manager/mod.rs`、`agent/codex_app_server/`、`agent/direct_project_history.rs`、`main.rs` 命令注册、ts-rs 生成目录、AGC 前端订阅与聊天投影、对应测试与 `docs/`。 +- 明确不修改:SpacetimeDB schema 与绑定、HTTP/OpenAPI、Codex durable thread 行为、审批弹层现有状态来源;DirectRuntime 的进度事件与账本本轮不动(该账本随后已随 issue #553 整体删除)。 - 保持 `.env` 未提交修改,不触碰个人配置。 ## 实现顺序 -1. Rust 只搬运:`agent/direct_thread_wire.rs` 把 Codex 原始条目挑字段、脱敏、截断后下发,事件载荷与历史切片同形,不生成卡片形状;线上模型是 ts-rs 导出的 tagged enum(`DirectThreadItem` / `DirectThreadEvent` / bootstrap / consume / history slice),`at` 标 `#[ts(as = "f64")]`,改完模型跑 `cargo test export_bindings` 生成前端绑定。 +1. Rust 只搬运:`agent/thread_manager/wire.rs` 把 Codex 原始条目挑字段、脱敏、截断后下发,事件载荷与历史切片同形,不生成卡片形状;线上模型是 ts-rs 导出的 tagged enum(`ThreadItem` / `ThreadEvent` / bootstrap / consume / history slice),`at` 标 `#[ts(as = "f64")]`,改完模型跑 `cargo test export_bindings` 生成前端绑定。 2. 条目身份归一:进队列前收敛成一个 `itemId`,事件 envelope 与前端形状里都不出现第二个 id 概念;历史切片的 `firstItemId` 继续取文件里的原始 item id。 3. 删掉回合身份:`turn.started` 无载荷、`turn.completed{status}`,条目 / 增量 / 请求 / 生命周期锚点都不带 turn id;队列 `append` 直接收 `DirectThreadEvent`,`seq` 内部自算。 4. 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 产出 `item.delta{kind:"reasoning"}`;plan 文本与命令输出保持活动状态。 5. 前端收敛为单一 reducer:`subscribe` 返回的 bootstrap 事件就是已暂存的运行态,游标已经在队尾,前端直接 reduce 这批事件即可(不需要为了拿这批事件再补一次 `consume`);此后只由 notify 唤醒 `consume`。唯一例外是回执竞态:Rust 注册完 subscriber 就开始通知,而前端要等回执才知道 `subscriptionId`,这段时间到达的通知只能记欠账,回执到达后立刻补一次 `consume`(否则整轮最后一个事件之后可能再无通知,事件会卡死在队列里)。合并规则只保留"先到定形、后到补空白"(正文只增不减、工具状态允许从 running 升级到终态),`item.delta` 直接追加到运行态条目正文,删掉 `deltaText` 缓冲,`turn.completed` 把运行态条目并入历史再清空。 6. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,历史读取改为从文件尾反向扫描;切片的新端边界由这个锚点给出(含该条,命令参数 `throughItemId`),首屏读取等订阅回执里的锚点,回执到达前不发请求、也不退化成「取文件尾」;之后锚点按原始 item id 推进(`beforeItemId`,不含锚点)。一次翻页操作在前端连拉,直到合并后聊天投影出现新回合(新的用户气泡)或 `hasMore=false`,每个操作上限 5 页;锚点未推进(`items` 为空 / `firstItemId` 为 null / 与请求锚点相同)时立即停止。可见性口径只在 `features/project-workspace/directHistoryPaging.ts` 实现一份,首屏与「显示更早」共用。 7. App.tsx 接线:订阅 + 立即 reduce bootstrap + notify 唤醒 consume,聊天视图改由 reducer 状态投影(含工具卡片),删除 Direct 回合事件订阅与 `directTurnStream` / `directToolCalls` 状态。 -8. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`),DirectRuntime 自己的写入保留。`list_game_creator_direct_active_turns` 是唯一的例外并有意保留:它服务首页跨页面的「运行中的项目」列表(`WorkspaceLauncher` / `directActiveTurns.ts`),不是聊天框读路径。 +8. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`)。`list_game_creator_direct_active_turns` 是唯一的例外并有意保留:它服务首页跨页面的「运行中的项目」列表(`WorkspaceLauncher` / `directActiveTurns.ts`),不是聊天框读路径。DirectRuntime 自己的账本写入本轮保留,随后已随 issue #553 整体删除。 9. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 ## 验证命令 diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md new file mode 100644 index 000000000..9048bfd04 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -0,0 +1,115 @@ +# 【里程碑】AGC 编辑器分支产物归位与症状层补丁清理 + +| 字段 | 值 | +| ----------- | --------------------------------------------------------------- | +| Version | 1.0 | +| Status | in-progress(2026-09-28 已过 Windows 真机核心评审;2026-09-29 完成打包一致性验收,客户端加载待有编辑器环境的机器;Cocos payload feature 集差异待裁决) | +| Date | 2026-09-26 | +| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | + +## 已实现(2026-09-27) + +- 声明新增三类「准备步骤」产物:`subdirectories[origin=prepared]`(Unity publish 目录)、`libraryStaging[].prepare + files`(Godot gdextension)、`nativePayloads[]`(Cocos bridge dll);`plugins.prepareSteps` 描述每个准备步骤的程序、工作目录、指纹与必需产物。 +- 准备步骤(`scripts/prepare-bundled-resources.mjs`)按声明执行 `powershell.exe -File build.ps1` 与 `cargo build -p … --target …`,用内容指纹跳过未变化的步骤,校验必需产物齐全后才复制;命中指纹且产物齐全时零写入。 +- 构建脚本删除了三处产物生成与整棵树复制(`prepare_unity_editor_helper`、`prepare_godot_editor_extension`、`stage_cocos_editor_payload`、`stage_build_generated_plugin_payloads` 及其辅助函数,共减少约 220 行),只保留只读校验:源码派生内容逐文件比对、已准备产物存在性、Godot 随包库沿用既有深度校验(`godot_bundle::validate`)。Godot 随包文件清单改由声明提供(单一来源)。 +- `.taurignore` 的两份 staging 条目已删除(构建期不再写 `resources/plugins`,无需忽略)。 +- 准备步骤的 Windows 侧命令路径已随系统性审计在真机复跑(powershell/cargo 两条路径、产物归位与幂等均已验证)。 + +## 目标 + +编辑器分支(Unity/Godot/Cocos)的随包产物也由准备步骤生成,构建脚本不再调用外部工具链产出随包资源;此前为绕开自触发问题而加入的症状层补丁与说明全部删除,实现形态与主规范一致。 + +## 范围 + +- 三个编辑器分支产物的生成职责迁出构建脚本,包括需要外部工具链的两条路径。 +- 构建脚本内与资源生成相关的规避手段删除:残留清理逻辑、为幂等而设的辅助常量与判断、开发监听忽略条目中与随包资源相关的部分。 +- 平台与特性开关(哪些平台、哪些特性才需要这些产物)在新形态下保持既有语义。 +- 与发布打包、包内资源门禁、运行时解析的一致性核对。 + +## 不在范围内 + +- 编辑器分支本身的接入协议、宿主能力与运行时行为。 +- 外部工具链版本管理与安装流程(沿用现状)。 +- 随包组件与插件工作区的生成形态(上一里程碑已完成)。 + +## 依赖与前置条件 + +- 前两个里程碑验收通过。 +- Windows 环境具备 Unity/Godot 分支所需的工具链(.NET 与 CMake 等),以便验证产物生成与打包。 + +## 验收标准 + +- [ ] 构建脚本中不再存在向随包资源目录写入的分支,也不再调用产出随包资源的外部工具链。 +- [ ] 三个编辑器分支的随包产物路径、内容摘要、可执行位与迁移前逐项一致,且由准备步骤稳定产出。(2026-09-29 验收:路径集合与源码派生内容、Unity/Godot 产物逐字节一致;Cocos payload 因声明只构建 `windows-injection` 而与迁移前不同,且 MSVC 链接产物本身不可字节复现,见下方验收记录,口径待裁决) +- [ ] 此前为规避自触发而加入的补丁(残留清理、幂等辅助、监听忽略条目)在代码与文档中全部移除,不再有「为了绕开构建问题」的说明。 +- [ ] 平台与特性开关语义不变:不支持的平台不产出这些资源,且不因此失败。 +- [ ] 全量门禁通过,且客户端在具备条件与不具备条件两种环境下都能给出明确结论(可用 / 缺组件及原因)。 + +## 验收记录(2026-09-29,Windows 本机) + +### 打包一致性(验收标准第 2 条) + +准备步骤按发布入口同样的参数复跑(`prepareBundledResources({ target: 'x86_64-pc-windows-msvc', features: Set('unity-editor-execute','godot-editor-execute','cocos-editor-injection'), profile: 'release' })`):`cocos-bridge-build 已构建`、`unity-helper-publish` / `godot-extension-build 命中缓存`、`plugins 重新生成`;随后连续三次复跑,`resources/plugins` 的 32 个文件内容与 mtime 均不再变化(稳定产出)。 + +出包走同一发布入口(`runTauriBuild` + `--bundles nsis`),并临时把 `bundle.createUpdaterArtifacts` 置 false——本机没有 updater 签名私钥,只出安装包;产物 `target/x86_64-pc-windows-msvc/release/bundle/nsis/陶泥儿开发版_0.1.67_x64-setup.exe`(166452206 B,sha256 `04a0be8ab8f54d8f032ce86d3e5c7a99c1dd6e81f71d49b63486827b11a01940`)。`tauri.windows.conf.json` 里 `resources/plugins → plugins` 是目录级映射。 + +包内核对:7z 解包得 2108 个文件,包内 `plugins/` 的 32 个文件与准备步骤产出的 `resources/plugins` **逐文件 sha256 完全一致**;`editor_adapters.rs` 的三个运行时相对路径(`UNITY_ATTACH_HELPER_RELATIVE` / `GODOT_BRIDGE_PAYLOAD_RELATIVE` / `COCOS_BRIDGE_PAYLOAD_RELATIVE`)加上声明里的必需产物共 17 项在包内全部命中。 + +迁移前对照取两份:本机已安装的 2026-09-24 包(`%LOCALAPPDATA%\陶泥儿开发版\plugins`,迁移前产品产物),以及把 `src-tauri` 整体切回合并基线 `8f59c034f` 后在同一个工作树里跑 `cargo build --release --target x86_64-pc-windows-msvc --features=unity-editor-execute,godot-editor-execute,cocos-editor-injection` 得到的 `resources/plugins`。三份对照路径集合一致,源码派生内容(JS/HTML/JSON/license/notice)逐字节一致,Unity helper、Godot 扩展逐字节一致。 + +两处已定性的差异: + +1. **Cocos payload 的构建 feature 集不同(需裁决口径)**:迁移前由同一次应用构建产出(`windows-bootstrap` + `windows-injection`,345088 B);准备步骤按声明只构建 `windows-injection`(26112 B,见 2026-09-27 决策日志条)。两者导出面完全相同(`DllMain`、`cocos_editor_bridge_bootstrap_source`),差掉的是宿主侧 bootstrap 传输(`reqwest`/`tungstenite`/`inspector`),注入进程不使用;但按「内容摘要与迁移前逐项一致」的字面判据不成立。 +2. **MSVC 链接产物不可字节复现**:同一 source / feature / profile / target 连续构建的 payload 摘要不同(除 PE `TimeDateStamp` 外还有 22 字节 RSDS GUID 差异);.NET publish 相反是确定的(Unity helper 跨两次重新发布逐字节一致),Godot 因 `buildId` 早退未重链也保持逐字节一致。因此「摘要一致」只在源码派生物、.NET 产物与命中工具链内部缓存的产物上成立。 + +新发现的风险(本轮未修,不影响上述结论): + +- 准备步骤的 `cocos-bridge-build` 与同一次应用构建写同一个输出路径 `target///deps/cocos_editor_bridge.dll`(两个 feature 单元同名产物),准备步骤的候选查找可能取到另一单元刚写下的文件;本轮实测两者交替后 cargo 会多一次重链。建议让准备步骤在独立 target 目录构建,或与应用的 feature 集对齐后从同一单元取产物。 + +### 客户端编辑器分支(验收标准第 5 条) + +本机未安装 Unity / Godot / Cocos Creator(`Program Files`、`UnityHub`、scoop shims 均无),进入编辑器分支只会停在「未检测到编辑器进程」,拿不到「helper/扩展被加载」的真机结论,因此**本轮未执行**,需在有三种编辑器的机器上补做。 + +已完成的自动化前段(本轮实测,用安装包解出的客户端、不安装): + +- 从 NSIS 包 7z 解出后直接运行 `genarrative-ai-game-creator-shell.exe`:窗口落在 `http://tauri.localhost/`(生产态嵌入前端,不是 `devUrl`),首页正常渲染,最近项目与模板库可见——说明包内 `plugins/`、`skills`、模板资源都被正确读取(对照:用 `cargo build --release` 直接编出来的 exe 没有 `custom-protocol`,会去连 `127.0.0.1:3080` 并落到 chrome 错误页,不能拿它当打包客户端)。 +- CDP 主世界(`page.target().createCDPSession()` + `Runtime.evaluate`)可读只读投影:`list_agc_plugins` 返回 `agc-cocos-editor` / `agc-unity-editor`(`builtin=true`、`hasRuntime=true`、`adapter` 正确),`list_agc_skill_catalog` 返回 8 条 —— 插件的 JS 入口与 Skill 包都按声明进包。 +- `agc-godot-editor` 插件不在该列表里符合现役语义:`plugin_host.rs::plugin_matches_project` 只在当前项目是 Godot 工程(根或一层子目录有 `project.godot`)时才让它可见。 +- 三种编辑器分支的判据入口:`require_plugin_adapter` 缺适配器时报「当前客户端不支持 X 编辑器桥接」,适配器在 payload/helper 缺失时报各自缺组件文案(如 Godot「插件缺少原生 DLL 资源」),编辑器没开时报探测失败——补做时要按这三类分开记录。 + +### 顺带定性:CI 唯一红项与本 PR 无关 + +run 2980(HEAD `0cf536b6`)唯一失败项是 `tests::sessions::background_agent_runtime_can_write_memory_and_project_files`(`src-tauri/src/tests/sessions.rs:405`,第二个 provider follow-up 请求 `recv_timeout(2s)` 超时): + +- 本 PR 对这条路径零改动:`src/` 下只有 `main.rs`(`#[cfg(test)]` 引入 `package_layout` 单测)与 `agent/codex_cli.rs`(去掉 `codex_package_metadata` 测试模块)两处**测试编译期**改动;`sessions.rs` 与 agent runtime 与合并基线逐字节相同。 +- 把 `src-tauri` 整体切回合并基线 `8f59c034f` 后,同一条命令在本机失败在同一断言(`second llm request: Timeout`)。 +- 本机实测第二个 follow-up 请求耗时 **5.18s / 4.87s**(两次),而测试预算 2s:该断言在本机裕量不足。master run 2979(lane 2 shard 3)也因另一条并发用例失败,属同一类时间预算抖动。 +- 结论:既有测试时间预算问题,不在本 PR 内顺手修。 + +## 验证步骤(Windows 侧执行清单) + +准备:切到本里程碑分支,`npm ci`(需装上 `@openai/codex-win32-x64`),确认 `spacetime --version` 与 `server-rs` 锁定版本一致(仅本地 dev 需要)。 + +1. **准备步骤单独跑(不打包)** + - `npm run agc:bundled-resources:prepare -- --target x86_64-pc-windows-msvc` + - 预期:一行汇总日志;`src-tauri/resources/plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe`、`agc-godot-editor/native/gdextension/` 下的扩展在位。 + - Cocos payload 只在 injection 构建下交付(与迁移前一致):加 `--features=cocos-editor-execute,unity-editor-execute,godot-editor-execute,cocos-editor-injection` 再跑一次,确认 `agc-cocos-editor/native/payload/cocos-editor-bridge.dll` 同时出现在插件工作区与随包目录。 + - 再跑一次:预期全部「命中缓存」,且 `resources/**` 的文件时间戳不变。 +2. **构建期不再写随包资源** + - 取 `src-tauri/resources` 全量快照(相对路径/大小/mtime/sha256)→ `touch apps/ai-game-creator-shell/src-tauri/build.rs` → 再 `cargo build` → 两次快照必须逐项一致。 +3. **构建新鲜度**:源码不变时连续两次 `cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`,第二次应为秒级 `Finished`,且不再出现 `Compiling genarrative-ai-game-creator-shell`。 +4. **打包一致性**:出一次 Windows 安装包,核对包内 `plugins/` 下三种编辑器分支产物的路径与 sha256 与迁移前一致;`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-run` 必须通过(会触发只读校验,所以要先跑过准备步骤)。 +5. **客户端启动**:进入 Unity/Godot/Cocos 编辑器分支各一次,确认对应 helper/扩展被加载,没有「缺少组件」类提示。 +6. **边界(负例)** + - 删掉 `plugins/agc-unity-editor/dotnet/publish/` 且让工具链不可用后打包:准备步骤必须给出明确失败原因(缺工具链/缺产物),而不是静默产出缺组件的包。 + - 删掉 `target/agc-resource-staging.json` 再跑准备步骤:预期重新生成,产物内容不变(丢缓存只多一次哈希)。 + - 删掉 `plugins/agc-unity-editor/dotnet/publish/win-x64/.agc-source.sha256` 后重跑准备步骤:预期重新执行 dotnet publish,而不是复用旧产物。 + - 手工改 `resources/**` 一个字节后 `cargo build`:只读校验必须失败(该规则覆盖 source 派生内容;prepared 产物只查存在性,见排障经验)。 + +记录:把每步命令、关键输出与结论贴回本里程碑或对应 PR;未通过项回到主规范 §9 记为未决问题。 + +## 证据要求 + +- 自动化:编辑器分支产物的摘要对比、平台门槛用例、配置门禁与包内资源门禁。 +- 运行时:Windows 上一次完整打包与一次客户端启动,确认编辑器分支产物被读取。 +- 边界:缺少外部工具链、缺少组件、非目标平台三种情形下的失败与跳过语义。 diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md new file mode 100644 index 000000000..9fb199b27 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md @@ -0,0 +1,51 @@ +# 【里程碑】AGC 随包资源改由校验器读入 + +| 字段 | 值 | +| ----------- | ----------------------------------------------------------- | +| Version | 1.0 | +| Status | completed(2026-09-27) | +| Date | 2026-09-26 | +| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | + +## 已定决策(2026-09-27) + +- **实现形态:混合**——生成归 Node(`scripts/prepare-bundled-resources.mjs`),声明与校验归 Rust。依据与对比见主规范 §4.8。 +- **单一声明**:`build_support/package-layout.json` 是唯一人工声明;Rust 侧使用由 `scripts/check-package-layout.mjs` 生成的编译期常量(`package-layout.generated.rs`),门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链;运行期 `codex_bundle.rs` 的公开接口与取值不变。 +- **校验收口边界**:本里程碑对随包 Codex 目录做全量校验(清单 schema/平台/版本、文件集合、逐文件摘要、第三方声明、可执行位、白名单外文件);插件随包目录只校验必需组件与符号链接。插件产物的逐文件摘要校验在 M2 由准备步骤写入树内清单后启用——M1 期间构建脚本仍整体重建 `resources/plugins`,树内清单会被清掉。 +- **独立验证入口**:`AGC_SKIP_RESOURCE_STAGING=1` 让构建脚本只跑只读校验、跳过写入分支。 + +## 目标 + +构建脚本不再需要「自己写随包资源」才能成立:在约定目录已有合规资源时,构建只做只读校验并通过;校验失败时给出明确原因并拒绝继续,而不是静默重新生成。 + +## 范围 + +- 随包资源的合规性判定:平台目录存在、清单 schema 与平台一致、逐文件摘要一致、必需组件齐全、版本与上游锁定一致。 +- 资源生成能力的可复用化:同一份布局与摘要校验能力既能被构建期校验使用,也能被准备步骤使用,不得出现第二份组件白名单。 +- 生成结果的稳定性要求:同一输入重复生成时,产物内容与文件时间戳不发生变化。 + +## 不在范围内 + +- 接入 dev 与发布入口(下一里程碑)。 +- 移除构建脚本里的资源写入分支。 +- 编辑器分支产物(Unity/Godot/Cocos)的生成方式与外部工具链调用。 +- 运行时资源解析顺序、完整性校验语义与打包配置里的资源映射。 +- Linux 产物支持。 + +## 依赖与前置条件 + +- 主规范第 4.3 与第 4.4 节的合同(准备步骤合同、构建脚本退化后的职责边界)。 +- 现有随包资源与清单已由当前实现产出,可用于校验回归。 + +## 验收标准 + +- [ ] 资源合规时,校验路径可独立运行并通过,不依赖构建脚本的写入分支。 +- [ ] 上游锁定版本、平台、逐文件摘要、必需组件四类不一致各自被拒绝,并给出可定位的原因。 +- [ ] 重复执行资源生成,产物内容与文件时间戳不变(幂等)。 +- [ ] 同一份布局与组件白名单只有一处声明,构建期校验与准备步骤共用。 + +## 证据要求 + +- 自动化:资源校验的通过/拒绝用例;同输入重复生成后目录快照对比(内容 + 时间戳)。 +- 运行时:本机在既有随包资源上运行一次校验与一次构建,确认资源被正常读取且构建行为与改造前一致。 +- 边界:目标平台不支持、上游缺失、摘要不匹配、目录被非本工具内容占用四种场景各自的失败输出。 diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md new file mode 100644 index 000000000..9486a877f --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md @@ -0,0 +1,48 @@ +# 【里程碑】AGC 随包资源生成接入 dev 与发布入口 + +| 字段 | 值 | +| ----------- | --------------------------------------------------------------- | +| Version | 1.0 | +| Status | in-progress(2026-09-27 起实施) | +| Date | 2026-09-26 | +| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | + +## 目标 + +随包资源在客户端开发与发布两条链上,都由启动/打包之前的准备步骤一次性生成;构建脚本不再承担生成职责,源码不变时构建不再重复编译。 + +## 范围 + +- 客户端开发的启动流程:在拉起客户端之前完成资源准备,命中缓存时不重写任何文件。 +- 发布打包流程:Windows 与 macOS 两条链在拉起打包工具之前完成资源准备,包括既有的运行时资源准备点。 +- 纯复制型资源(随包组件与插件工作区)的生成职责从构建脚本迁出。 +- 不打包场景(仅校验、不产包)的放行口径。 + +## 不在范围内 + +- 编辑器分支产物(Unity/Godot/Cocos)的生成方式与外部工具链调用(下一里程碑)。 +- 打包配置里的资源映射、包内资源门禁与安装包形态。 +- 运行时资源解析与完整性校验语义。 +- 构建脚本中与资源无关的既有职责(配置能力、提示词产物、元数据)。 + +## 依赖与前置条件 + +- 上一里程碑的验收通过:资源校验可只读通过、生成幂等、白名单唯一。 +- M1 交付的准备步骤与声明门禁已在位:`apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`(Codex 与插件两条纯复制路径,写临时目录后原子替换,命中缓存不重写)与 `npm run agc:bundled-resources:check`(已进 `agc:typecheck` 链)。本里程碑需要让准备步骤改为在 `resources/plugins` 内写入自己的清单,并停止构建脚本对该目录的整体重建,插件产物的逐文件摘要校验才能启用。 +- 开发与发布两条链在拉起客户端/打包工具之前都有明确可插入的准备阶段。 +- Windows 与 macOS 均需具备可验证的开发环境(两个平台各自验收)。 + +## 验收标准 + +- [x] 客户端开发启动一次成功:不再出现因资源变更而触发的重复构建,客户端与运行器进程稳定存活。(证据:清理 `AGC` dev 探针——`tauri dev` 全程 `Rebuilding application` 0 次、`Running DevCommand` 1 次、主 crate 仅编译 1 次,app 起来后持续处理项目;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(401 InvalidSignature / 502 Bad Gateway)阻断,属既有本机环境问题。) +- [x] 源码不变时连续两次构建,第二次为秒级完成;构建脚本声明的输入中不再出现随包资源路径。(证据:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒;强制构建脚本重跑后 `resources/codex` 与 `resources/plugins` 快照逐项不变。) +- [ ] Windows 与 macOS 打包产物中的随包资源,与迁移前逐项一致(路径、内容摘要、可执行位)。(macOS 侧 `check-macos-bundle.mjs` 待打包验证;Windows 待 M3 归位三处构建期产物后复验。) +- [x] 准备步骤连续执行两次不改变产物内容与时间戳;缺少准备步骤时,打包与启动以明确错误失败,而不是静默产出缺组件的包。(证据:准备步骤 10 条用例含幂等、上游缺失、上游元数据漂移与失败关闭;`AGC_SKIP_RESOURCE_STAGING=1` 在既有产物上只读通过;构建脚本校验缺失组件时 fail closed。) +- [ ] 本机 Rust 门禁(会触发构建脚本的测试入口)与不打包构建路径仍然可用。(macOS 侧已验;Windows 的 `check:rust:shell` 待真机确认。) + +## 证据要求 + +- 自动化:构建新鲜度日志、构建脚本输入清单、准备步骤幂等快照、包内资源门禁脚本结果。 +- 运行时:macOS 与 Windows 各一次客户端启动,确认随包组件被读取而非回退到外部安装。 +- 边界:缺少准备步骤、缓存命中、上游锁定变化三种情形下的行为。 +1 \ No newline at end of file diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md index d75871953..b2c376613 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -11,7 +11,7 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话历史**(`.agent/conversations/project.jsonl`)与 **运行态事件**(Thread Manager `subscribe` / `consume` / `notify`)。Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl` 与活动回合快照都不再是聊天视图的输入。 -边界固定为:Thread Manager 只是**搬运层**——把 Codex 原始条目挑字段、脱敏、截断后下发;工具卡片的形状、可见性与合并全部由前端投影完成。线上模型是 ts-rs 导出的 tagged enum(`agent/direct_thread_wire.rs`),条目身份只有一个:Rust 在进队列前归一成一个 `itemId`,不再暴露第二个 id 概念,也不带任何回合身份。 +边界固定为:Thread Manager 只是**搬运层**——把 Codex 原始条目挑字段、脱敏、截断后下发;工具卡片的形状、可见性与合并全部由前端投影完成。线上模型是 ts-rs 导出的 tagged enum(`agent/thread_manager/wire.rs`),条目身份只有一个:Rust 在进队列前归一成一个 `itemId`,不再暴露第二个 id 概念,也不带任何回合身份。 ## 范围 @@ -27,7 +27,7 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - 审批、提问与用户输入请求的状态机迁移;本次事件只作同一条流的 pass-through。 - 跨进程回合账本、按回合统计与持久 turn ledger。 -- DirectRuntime 自己的进度事件与该运行时仍在使用的 `turn-stream.jsonl` / `tool-calls.jsonl` 写入:它们属于运行时的账本,本轮只切 DirectProject 聊天框的读路径。 +- DirectRuntime 自己的进度事件与该运行时的账本:本轮只切 DirectProject 聊天框的读路径;该账本(`turn-stream.jsonl` / `tool-calls.jsonl`)随后已随 issue #553 整体删除。 - 旧项目磁盘上既有投影文件的清理、迁移或回填。 - 非 DirectProject 运行时、Codex durable thread 语义、SpacetimeDB 与 HTTP 契约。 @@ -40,7 +40,7 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - 思考正文流式下发不放宽可见范围:被下发的就是此前已在 `item.completed` 展示、并已落进 `project.jsonl` 的同一段文本。 - 失败与中止说明只在运行期显示(不写 `project.jsonl`),页面重进后不再出现。 - 未知 item 类型由 Rust 原样透传(只有类型与身份,Rust 侧留 TODO),当前由前端投影丢弃。 -- 前端聊天卡片的工具形状是 `Omit`;`tool-calls.jsonl` 的持久化形状与 DirectRuntime 的写入保持不变。 +- 前端聊天卡片的工具形状是 `Omit`。 - 「可显示」的判据取**前端回合反馈**:一次翻页操作连拉到「合并后聊天投影的回合数增加」为止。工具卡片与思考文本虽然能通过 `projectDirectThreadItem`,但可能整页落进已渲染回合的折叠「执行过程」,不构成用户可见反馈;口径只在 `directHistoryPaging.ts` 里实现一份,首屏与「显示更早」共用。 - 首屏切片的**新端边界**只认 `subscribe` 回执里的 `lastCompletedItemId`(含该条):回执到达之前不读首屏,也不退化成「取文件尾」;锚点缺失(订阅不可用 / 失败 / 历史为空)时才按文件尾取尾屏,手动重读保持按当前文件尾取尾屏的恢复语义。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 09d50f630..0ee0f21ac 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6,6 +6,21 @@ - 决策:生产 vhost 以 `deploy/nginx/genarrative.conf` 为唯一来源;生产机专属路径收进 `deploy/nginx/snippets/genarrative-host-extras.conf`,由主模板 include、由 Server-Provision 安装,新增平台路由仍必须在主模板内声明并同步 `deploy/pingora/nginx-route-parity.matrix.json` 与 Pingora 网关。snippet 内的 location 有意不在矩阵覆盖范围,Pingora 接公网 443 前必须单独确认这些路径的处理方式。 - 边界:退役路由(`/match3d`、`/puzzle`、`/runtime/*`、`/gallery/*/detail`、`/works/detail`、`/worlds/detail`、`/bark-battle` 等)不再进 SPA allowlist,也不配置显式 404,统一落 `location /` 的 `error_page 404 /404.html`。`/home/` 是 2026-08-11 官网拆分前的历史入口,是否退役需与官网侧一起决定。 - 验证方式:`node scripts/check-nginx-spa-routes.mjs`、`node scripts/check-pingora-route-parity.mjs`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`、`bash -n scripts/jenkins-server-provision.sh`;线上改后按生效配置烟测 `/profile`、模板 12 条 SPA 路由、`/admin/`、`/home/`、`/finance-forecast/`、`/medical-science/`、画廊 API 与 `/games/game_/…` 发行网关。 +## 2026-10-01 DirectProject 审批拒绝原因留痕 + +- 决策:宿主拒绝 app-server 的审批 / 交互请求时,原因必须落 AppData `RUST` 日志。稳定键 `agent.direct_codex.approval.denied`,字段为 `thread_id` / `method` / `reason` / `distinct_reasons`;未绑定宿主执行器时另记 `agent.direct_codex.interaction.no_adapter`(`method` / `outcome`,`outcome` 区分 `decline` / `empty-permissions` / `unsupported-method`);回包未送达另记 `agent.direct_codex.approval.response_delivery_failed`,`cause` 区分 `response-write-failed` 与 `turn-bind-mismatch`。 +- 原因:线上对审批只回 `{"decision":"decline"}`,模型与用户都看不到是哪一道闸门(合同缺失 / 预算耗尽 / 阶段已收束 / item 不在途 / 无适配器)拦下的,只能靠复现。 +- 边界:`reason` 只接受 `execution.rs` 内的静态分类或宿主自己的错误文本;透传的宿主错误在留痕出口统一走 `sanitize_diagnostic_message`,不带请求参数、上游正文、路径或凭据(去重键也用脱敏后的文本)。有界去重一律按 `MAX_DENIED_REASON_LOGS = 64` 条封顶——适配器路径按回合内 `(method, reason)`、未绑定执行器路径按进程级 `(method, outcome)`、回包未送达按回合内原因各只记一次。拒绝留痕在释放审批临界区(`state`)之后进行;回合收束说明按 `cause` 区分,只有 `response-write-failed` 才说「回包丢失」,`turn-bind-mismatch` 说明作用域无法确认。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs`。 +- 验证:`cargo test --bin genarrative-ai-game-creator-shell agent::codex_app_server::execution::tests`(15 passed,含 `denied_approval_records_its_reason_once_per_turn`、`denied_reason_logging_is_bounded`)、`npm run check:encoding`、`git diff --check`。 + +## 2026-09-30 退役 DirectProject 工具调用与回合流账本(issue #553) + +- 背景:`tool-calls.jsonl` / `turn-stream.jsonl` 是只写不读的账本——读命令在 2026-09-16 随聊天真相源收敛删除后,唯一"消费方"是 `game-creator-direct-turn-update` 事件,而全仓已无监听方;工具卡片的真实读路径早已是「项目对话历史 + 运行态事件」的前端投影。issue #553 还暴露了同一时期绝对路径脱敏误伤 HTML 结束标签的问题。 +- 决策:整体删除 DirectRuntime 的 `turn-stream.jsonl` / `tool-calls.jsonl` 写入器、`DirectToolCallCollector`、回合流节流器、`DirectCodexTurnObservation::ToolCall` 与 `GameCreatorDirectTurnUpdateEvent`;`update_active_turn` 保留,首页「运行中的项目」快照不变。工具卡片只由读取期在 `agent/thread_manager/wire.rs` 脱敏、截断的历史条目投影。 +- 边界:不在运行时做磁盘清理;旧项目里可能残留的文件不迁移、不读取,历史由 Git 保存。 +- 未修(另开):`redact_absolute_path_tokens` 的无语境绝对路径扫描仍会把 HTML 结束标签、嵌套 JSON 转义里的 `/` 误判成路径,是 issue #553 的根因;本次只删死路径,未改扫描器。 +- 验证:`cargo test --bin genarrative-ai-game-creator-shell agent:: -- --test-threads=1`(929 passed / 0 failed / 5 ignored)、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 ## 2026-09-30 release 渠道移除产品名与包名后缀 @@ -2119,6 +2134,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:主站与图片画板统一复用公共泥点资产入口,收起态展示总额与充值,展开态只展示不限时泥点、每日免费泥点和使用详情;充值中心 BFF 继续统一下发总额、三桶余额、限时到期时间、每日免费基础重置额及下次重置时间,前端不得自行相减推算,但会员周期限时泥点仅用于存量兼容和后端结算,当前版本不在前台展示。钱包明细每次展开都重新读取充值中心 BFF,打开期间实时总额变化时继续补读;图片画板的生成扣费或退款完成后同时刷新总额与充值中心拆分。充值中心读请求必须使用 revision 门禁,支付创建、到账确认等权威响应写入时使旧读失效,避免旧响应覆盖新的每日免费 / 不限时明细。默认泥点商品收敛为 `60 / ¥6`、`180 + 90 / ¥18`、`300 + 150 / ¥30`、`680 + 340 / ¥68` 四档,`60` 档无赠送,后三档按现有 `user_id + product_id` 独立资格规则首次购买加赠 `50%`。当前版本关闭会员购买页签、会员商品和购买 / 升级入口。 - 2026-07-17 追加:主站、图片画板与 AI 游戏创作独立 App 的泥点账单统一复用 `packages/shared/src/components/PlatformProfileWalletLedgerModal`。共享组件只依赖 `ProfileWalletLedgerResponse`,承接来源 label、金额正负号、UTC 日期、余额兜底和 loading / empty / error 展示;`/api/profile/wallet-ledger` 请求、鉴权、打开状态与重试生命周期继续由各宿主持有,不把账户事实或后端副作用下沉到共享 UI。 - 2026-09-07 追加:资产扣费在既有钱包流水 metadata 中记录服务端确定的 `assetKind`,`GET /api/profile/wallet-ledger` 只把白名单类型映射为可选用户文案 `reason`,不暴露原始 metadata、资源 ID、任务 ID 或未知内部枚举。共享账单组件优先展示非空 `reason`;历史、未知和空 metadata 继续按 `sourceType` 回退为“资产操作消耗”,不得由客户端猜测业务类型。 +- 2026-10-01 追加:账单不计入实际变动为 0 的条目,规则落在 SpacetimeDB 侧而不是展示层。写入侧只在余额真的变化时落账:每日免费 / 会员周期换期按 `resolve_expiring_points_wallet_transition` 先算出“换期后余额 + 实际变动”,变动为 0(含到期额度等于发放额度、余额不足以扣回到期额度、额度与发放都为 0)时只更新余额状态、不写 `profile_wallet_ledger`;通用结算入口 `apply_profile_wallet_signed_delta` 在 0 变动时同样直接返回当前余额。读取侧 `list_profile_wallet_ledger_entries` 在按余额结算链排序之后、截断 50 条上限之前剔除 `amount_delta == 0` 的存量行,因此历史遗留的 0 流水也立即从账单消失,且不占用列表上限、不改变其余条目的先后与 `balance_after` 语义。此前 `update_profile_wallet_balance_for_expiring_points` 只判断“到期额度与发放额度都为 0”,判的是入参而不是实际余额变动,所以余额不变的换期流水仍会落账并显示成 0。 - 影响范围:`profile_recharge_product_config` 默认商品、充值中心 read model、共享前后端契约、主站与图片画板泥点资产入口、充值弹窗、后台充值商品默认值。 - 验证方式:充值与统一入口定向前端测试、`npm run typecheck`、充值商品定向 Rust 测试、`cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 @@ -9086,6 +9102,35 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证(真实上游 smoke,本地 dev DB):清空 `agc_model_catalog` 后启动 api-server → 日志 `已按上游模型列表初始化 AGC 模型目录 revision=1 model_count=6`;登录后 `GET /api/llm/models` 返回同一批模型、`displayName` 即上游原名、默认项为排序后第一项;上游不可达/非 2xx 时启动只记录 error、AGC 接口 `503` 且目录保持未初始化;目录已存在时重启不重写。 - 边界(未验证/残留):上游在售模型超过 32 条时同步会失败(目录项上限未改);`qwen-image-3.0` 这类图像模型会一起进入目录,是否对 AGC 隐藏由 owner 在后台停用;混合版本期间未升级的 api-server 会把自己的 AGC 接口打到 `503`,module 与 api-server 必须同批发布/回滚。 +## 2026-09-27 AGC 随包资源改为「单一声明 + 准备步骤生成 + 构建期只读校验」 + +- 背景:随包资源(内置 Codex CLI、插件工作区)由 `build.rs` 在构建期写入 `src-tauri/resources/**`,而这些路径同时被 tauri 配置的 `bundle.resources` 登记成构建输入,cargo 因此永远判 stale:Windows/macOS 每次构建重编主 crate(41–87 秒),macOS dev 反复 `Rebuilding application`、客户端起不来(issue #519)。三轮症状层修复(内容比对、权限跳过、`.taurignore`)都只减少写入次数,没有改变「构建期写被登记文件」这一结构。 +- 决策(形态:混合):准备步骤用 Node(复用 `stage-node-runtime.mjs` / `prepare-macos-codex.mjs` 的下载、`integrity`、临时目录 + rename 原子替换),布局与摘要校验留在 Rust(复用 `codex_bundle.rs` / `godot_bundle.rs`),运行期模块公开接口与取值不变。 +- 决策(单一声明):唯一人工声明是 `apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`(Codex 三元表与组件白名单、上游候选路径、第三方声明来源、插件随包子目录与跳过规则、平台与 feature 门槛)。Node 直接读该 JSON;Rust 读由 `scripts/check-package-layout.mjs` 生成的 `package-layout.generated.rs` 编译期常量(不解析 JSON、不引入生命周期妥协)。门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,同时校验声明自身不变量:目标唯一、`executable` 属于白名单、每个目标恰有一条第三方声明来源,且 `codex.version` 与应用锁定的 `@openai/codex` 一致。 +- 决策(校验与独立入口):`build.rs` 新增只读校验——Codex 目录校验清单 schema/平台/版本、文件集合、逐文件 sha256、第三方声明、可执行位与白名单外文件;插件目录校验必需组件与整树符号链接。`AGC_SKIP_RESOURCE_STAGING=1` 可跳过写入分支、只跑校验,用于在既有产物上单独验证校验路径。插件产物的逐文件摘要校验留到 M2(届时准备步骤在树内写清单,不再被构建脚本整体重建覆盖)。 +- 影响面:`apps/ai-game-creator-shell/src-tauri/{build.rs,build_support/**}`、`apps/ai-game-creator-shell/scripts/{check-package-layout.mjs,prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs}`、`apps/ai-game-creator-shell/package.json`、根 `package.json`、`.gitignore`、AGC 技术方案 §4.8/§8/§9、M1 里程碑规范与实施计划、开发运维文档。三份 tauri 配置的 `resources` 映射与包内路径不变。 +- 验证:`npm run agc:bundled-resources:check`;`npm run agc:bundled-resources:test`(9 passed,含幂等、上游缺失、非本工具目录、目标不支持、dry-run);`AGC_SKIP_RESOURCE_STAGING=1 cargo check --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 在准备步骤产物上通过;准备步骤产物与构建脚本产物逐文件一致(相对路径、大小、sha256);连续两次运行准备步骤第二次全部命中缓存,目录快照(含 mtime)不变。 +- 边界(未验证):准备步骤尚未接入 dev 与发布入口(M2);Windows 真机的构建新鲜度与打包未验证;Unity/Godot/Cocos 产物仍由构建脚本生成(M3);Linux 上五条 staging 与校验均为 no-op。 + +## 2026-09-27 AGC 随包资源准备步骤接入 dev 与发布入口,构建脚本退出写入 + +- 背景:M1 只交付了单一声明、准备步骤与只读校验,构建脚本仍在写随包资源,所以 macOS 的 `npm run agc` 仍会因 `resources/codex/mac-native` 被重写而反复重建、`cargo build` 每次重编主 crate(41–87 秒)。 +- 决策(接线):`start-tauri-dev.mjs` 在前端与配套后端就绪之后、spawn Tauri CLI 之前调用准备步骤(命中缓存零写入,日志前缀 `[ai-game-creator-shell]`);`build-release.mjs` 的 `runTauriBuild` 与既有 `stageRuntime(target)` 并列调用 `stageBundledResources(target)`,`tauri build --no-bundle` 仍不强制 staging。两处都保留依赖注入,便于入口测试断言调用顺序与 no-bundle 行为。 +- 决策(写入边界,声明新增 `origin`):`origin: source`(Codex 组件、插件 `src`/`panels`/`skills`/`native/payload`)由准备步骤写;`origin: build`(Unity `dotnet/publish/win-x64`)与外部工具链产物(Godot `native/gdextension`、Cocos payload)由构建脚本在产物生成后写。构建脚本删除 codex 与插件白名单的写入分支及 `stage_plugin_file`/`copy_plugin_tree`/`copy_plugin_file`,改为 `stage_build_generated_plugin_payloads`。 +- 决策(契约收口):插件随包工作区改为与仓库源码逐文件比对(清单 + 逐文件 sha256 + 整树符号链接,构建期派生内容只查存在性),实现移入 `build_support/package_layout.rs` 以复用单测;上游原生包元数据(layoutVersion/version/target/entrypoint/resourcesDir/pathDir)改由准备步骤按声明校验,`build_support/codex_package_metadata.rs` 因失去调用方而删除。准备步骤改为同步实现(全部是本地同步 IO),入口可直接调用而无需子进程。 +- 影响面:`apps/ai-game-creator-shell/scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,start-tauri-dev.mjs,build-release.mjs,build-release.test.mjs}`、`apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts`、`src-tauri/build.rs`、`build_support/{package_layout.rs,package-layout.json,package-layout.generated.rs}`(`codex_package_metadata.rs` 删除)、`src/agent/codex_cli.rs`、技术方案 §4.9、M1/M2 里程碑与运维文档。 +- 验证:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒全程 fresh;强制构建脚本重跑(`touch build.rs`)后 `resources/codex` 与 `resources/plugins` 的快照(相对路径/大小/mtime/sha256)逐项不变;`cargo test --no-default-features … package_layout` 36 passed;`node --test scripts/prepare-bundled-resources.test.mjs` 10 passed(含上游元数据漂移被拒);`node --test scripts/build-release.test.mjs` 39 passed(含 `stage → bundled → build` 顺序与 no-bundle 不 staging);`npx vitest run tests/start-tauri-dev.test.ts` 12 passed(含「准备步骤先于 CLI 启动」)。 +- 边界(未验证):Windows 真机未验证,且 Unity publish 目录、Godot gdextension、Cocos payload 仍是构建期写入,Windows 构建新鲜度要等 M3 归位;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(先后 401 InvalidSignature 与 502 Bad Gateway,属既有本机环境问题)阻断,未跑通整条 dev 启动链路。 + +## 2026-09-27 AGC 编辑器分支产物归位:构建脚本彻底退出写入 + +- 背景:M2 之后构建脚本仍生成 Unity publish 目录、Godot gdextension 与 Cocos bridge payload,这三处写入落在 `resources/plugins/**`(`bundle.resources` 映射目录),Windows 上仍会触发每次重编,`.taurignore` 的 staging 条目也还不能删。 +- 决策(声明扩展):`subdirectories` 新增 `origin: prepared`;`libraryStaging` 增加 `prepare` 与 `files`;新增 `nativePayloads` 与 `plugins.prepareSteps`(程序类型、工作目录、指纹、必需产物)。Godot 随包文件清单改由声明提供——`godot_bundle::BUNDLE_FILES` 从生成的编译期常量取值,不再各写一份。 +- 决策(准备步骤执行器):准备步骤按声明运行 `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File build.ps1`(Unity/Godot,Godot 额外移除 `PSModulePath`)与 `cargo build -p cocos-editor-bridge --target … --features windows-injection`;内容指纹命中且必需产物齐全时零写入;命令执行器可注入,便于在 macOS 上用假执行器覆盖调度、指纹与失败关闭逻辑。 +- 决策(构建脚本瘦身):删除 `prepare_unity_editor_helper`、`prepare_godot_editor_extension`、`stage_cocos_editor_payload`、`stage_build_generated_plugin_payloads` 及其辅助函数(build.rs 415 → 193 行),只留只读校验,并新增「已准备产物存在性 + Godot 随包库深度校验」;`AGC_SKIP_RESOURCE_STAGING` 开关随写入分支一并删除;两份只含 staging 条目的 `.taurignore` 删除。 +- 影响面:`apps/ai-game-creator-shell/src-tauri/build_support/{package-layout.json,package-layout.generated.rs,package_layout.rs,godot_bundle.rs}`、`src-tauri/build.rs`、`scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,check-package-layout.mjs,build-release.mjs}`、两份 `.taurignore`、技术方案 §4.9/§8、M3 里程碑、运维文档、决策日志与排障经验。 +- 验证:准备步骤 13 条用例通过(含三类准备步骤调度、指纹跳过、缺产物失败关闭、幂等与失败关闭);`npm run agc:bundled-resources:check` 通过;`cargo check --no-default-features` 通过(构建脚本仅剩只读校验,且不再出现在随包资源的写入路径上)。 +- 边界(未验证):Windows 真机未验证——powershell/cargo 两条命令路径、Unity/Godot/Cocos 产物归位、包内容一致性与客户端加载,需按 M3 里程碑的验收清单在 Windows 上确认。 ## 2026-09-24 命令入队化与待发消息队列归宿主:放行归 Thread Manager,CLI 直连入口退役 - 决策(词表):「接单 / 拒单」退役,命令边界的成功与失败改叫「入队 / 入队失败」;旧「接单」的语义角色 @@ -9265,7 +9310,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 列表排序(新增能力,同日):`AdminListPanel` 增加 `sortable` / `sortValue` / `sortDescription`——列头渲染与表查询一致的排序按钮(`admin-table-sort-button` + 升/降/双向图标),点击按「正序 → 倒序 → 不排序」循环,同步 `th[aria-sort]`,排序是稳定排序(值相同保持服务端原顺序)。已接入**一次取全**的 5 个列表:邀请码列表、操作记录、灰度 Gate 列表、可配置开关、任务配置列表(这些接口没有分页,前端排序语义正确)。 - 列表实现全量收口(同日续做):把剩余 **15 处** `children` 形态列表全部迁到 `columns` + `renderRow`(表体 JSX 原样搬进 `renderRow`,外壳/状态/分页槽位不变):`AdminRedeemCodePage`(2)、`AdminRechargeProductPage`、`AdminProjectSnapshotsPage`、`AdminErrorReportsPage`、`AdminAgcTrackingPage`、`AdminGameDistributionReviewPage`、`AdminGameManagementPage`(主列表 + 版本历史)、`AdminUserDetailDialog`(充值订单)、`AdminRechargeOrderPage`、`AdminAgcModelsPage`、`AdminEditorAssetQueryPage`、`AdminEditorShowcaseReviewPage`、`AdminAgcTemplatesPage`。迁移后全仓统计:`AdminListPanel` **26 处 / 21 个文件**,其中 **24 处 columns 形态**(含 2 处弹窗内 `surface="plain"`),仅剩 2 处非表格列表仍是 children 形态(账号管理卡片列表、账号配置的键值列表);`` 作为 children 的写法已归零。可排序列累计 **45 个**(新增写入 `AdminAgcTemplatesPage` 列头 4 个:模板/引擎版本/包大小/状态)。 - 迁移中顺带处理的形态差异:① `AdminErrorReportsPage` 的详情弹窗原本嵌在列表面板里,随表格一起搬到面板外(遮罩是 fixed,视觉不变);② `AdminAgcModelsPage` 的工具栏与状态行改为走 `toolbar` 槽位(列表面板按 toolbar → 加载行 → 表格渲染,顺序与原来一致);③ `AdminAgcTemplatesPage` 的列表从共享 `ui/Table`(`genarrative-ui-table*`)换成 `AdminTable`,与其它页签视觉统一——该页唯一的 ui Table 只剩「上传模板」弹窗里的待上传队列表(有逐行校验/进度状态的编辑态表格,未纳入列表组件,属有意保留)。 -- 排序能力边界(重要):后端目前**只有** `GET /admin/api/database/tables/{table}/rows` 与 `GET /admin/api/external-api-keys` 接受 `sortColumn`/`sortDirection`;其余列表在 handler 里写死顺序(例如埋点数据固定 `occurred_at desc`、错误报告按时间倒序)。因此埋点数据、客户端埋点、错误报告、项目工程、充值订单这类**分页明细列表暂时不能排序**——只在前端排「当前页」会给出错误结论,必须给对应接口加排序参数(DTO + handler + `adminApiTypes` + 契约/测试)后前端复用同一列头。账号管理是卡片列表(`children` 形态),本轮未加排序。 +- 排序能力边界(重要):后端目前**只有** `GET /admin/api/database/tables/{table}/rows` 与 `GET /admin/api/external-api-keys` 接受 `sortColumn`/`sortDirection`;其余列表在 handler 里写死顺序(例如埋点数据固定 `occurred_at desc`、错误报告按时间倒序)。埋点数据、客户端埋点、错误报告、充值订单等分页明细不能只在前端排「当前页」,必须先让对应接口支持全局排序,再复用列头。项目工程采用独立的主动全量读取按钮:当前渠道完整读取成功后按同步时间降序、用户/项目 ID 升序,本地分页;原目录接口及上传/下载契约保持不变,不新增 OSS 索引或数据库表。读取限制为 200 页、10,000 个唯一项目、60 秒,可取消;失败保留原列表,切换渠道/令牌取消请求并销毁临时集合。账号管理是卡片列表(`children` 形态),本轮未加排序。 - 本地假数据补齐:`scripts/admin-web-fake-api.mjs` 新增 `agc-models`、`game-distribution/games`、`profile/recharge-products`、`profile/redeem-codes`、`profile/tasks`、`agc/tracking-events` 夹具,并对 `profile/recharge-orders` 按 `AdminRechargeOrderEntryPayload` 的真实字段补齐(缺字段会让页面抛 `Cannot read properties of undefined`——后台没有 error boundary,整页会白屏)。**已知缺口**:充值管理页仍缺一处夹具字段(页面读 `undefined.find`),本轮没能出图;该页自身 21 条单测通过、类型检查通过,仅缺截图。 - 排序验证:共享组件新增用例覆盖「正序 / 倒序 / 取消 + aria-sort + 行序」;现场实测邀请码列表按「创建」排序:正序 `EXPIRED-CODE, BETA-CREATOR, TAONIER-VIP-2026`、倒序翻回 `TAONIER…, BETA…, EXPIRED…`,`th[aria-sort]` 依次为 `ascending` / `descending`。 - 边界(未完成):未跑生产后台构建与真实后台接口联调(截图用假数据);`apps/admin-web` 目前没有 error boundary,任何接口形状不符仍会把整页渲染清空(本次只加固了 `AdminAgcTrackingPage` 一处,其余页面同类写法未逐个排查);`AdminAgcTemplatesPage` 的列表面板是本次新增的外壳(原页面没有面板),视觉上多了白底卡片。 @@ -9283,3 +9328,15 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 后台 AGC 模型目录新增 `agentMode`,只允许 `codex` / `cc`,缺失的历史目录按 `codex` 兼容;模型选择返回的公开摘要同步携带该绑定。 - 客户端把后台 `codex` 映射到现有 Codex app-server,把 `cc` 映射到独立 Claude Code CLI adapter;不通过替换 Codex JSON-RPC 可执行文件实现。 - Claude Code 只使用隔离环境和 AGC loopback MCP,禁用原生工具;取消通过独立 Direct 回合进程树回收处理。Codex、provider 和自定义 Responses 链路保持原路径。 + +## 2026-09-30 合入 master 时把 Claude Agent SDK sidecar 归位到随包资源准备步骤 + +- 背景:master `fb130d184` 新增 `cc` 执行模式与 Claude Agent SDK sidecar,sidecar 的 staging 写在 `build.rs`(构建期 `remove_dir_all` + 从 `node_modules/@anthropic-ai/**` 复制 `resources/claude-agent`),同时把 `resources/claude-agent` 映射进**基线** `tauri.conf.json`。本分支的 M1–M3(issue #519)已把「构建期写随包资源」定性为结构问题,合入时必须按同一套架构落地,不能把写入分支带回来。 +- 决策(归位实现):新增声明 section `claudeAgent`(锁定版本、资源目录、sidecar 入口源码、上游 SDK 包与平台原生运行时包的目标表、复制跳过规则)。Node 准备步骤整目录原子替换 staging,缓存 key = `layoutVersion + target + 声明版本 + 入口摘要`,命中即零写入;`build.rs` 只读校验:入口与仓库源码逐字节一致、SDK 与原生运行时 `package.json` 版本等于声明、原生运行时在位(unix 还要求可执行位)、随包目录里没有白名单外的文件。 +- 决策(版本单一真源):`CLAUDE_AGENT_SDK_VERSION` 由声明生成;`claude_code_cli.rs` 的 sidecar 身份串改用 `cargo:rustc-env=AGC_CLAUDE_AGENT_SDK_VERSION`,门禁断言声明版本等于 `apps/ai-game-creator-shell/package.json` 与 `agent-sidecar/package.json` 锁定的 `@anthropic-ai/claude-agent-sdk`。 +- 决策(平台映射从基线配置移到平台配置):`resources/claude-agent` 由 `tauri.conf.json` 移入 `tauri.windows.conf.json` 与 `tauri.macos.conf.json`。基线配置同时服务 Linux——CI 只在那里编译壳 crate 且按设计不装 npm 依赖,而 `tauri-build` 会把 `bundle.resources` 的每个路径拷进 target、缺失即失败;留在基线等于要求一份只有 Windows/macOS 才产出的资源。`check-config.mjs` 增加「基线不得声明 `resources/**`」的守卫。 +- 决策(删掉自带的清理逻辑):不保留 master 的 `prune_stale_codex_components`。准备步骤对 staging 单元整目录原子替换已经清掉旧布局残留,构建期另有「白名单外的文件」断言;构建脚本不再删任何人的文件。 +- 影响面:`src-tauri/build_support/{package-layout.json,package-layout.generated.rs,package_layout.rs}`、`src-tauri/build.rs`、`scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,check-package-layout.mjs,check-config.mjs}`、三份 tauri 配置、`src/agent/claude_code_cli.rs`、技术方案 §4.5/§4.9、排障记录。 +- 验证:`npm run agc:bundled-resources:test`(18 passed,含 sidecar 归位、跳过规则、上游缺失、版本漂移、目录被占);`npm run agc:bundled-resources:check`、`check-config.mjs`、`cargo test --bin genarrative-ai-game-creator-shell package_layout::tests`、`cargo check --no-default-features`、`cargo fmt --check`、`check:encoding`、eslint/prettier 全部通过;Windows 真机准备步骤 staging 24 个文件(含 243MB `claude.exe`)后 `cargo check` 不再出现构建期写入。 +- 边界(未验证):macOS 真机的 sidecar 加载与 `check-macos-bundle.mjs` 包内容门禁未在本机验证;Linux 门禁按新配置不再要求 sidecar 资源,需 CI 实跑确认转绿。 +- 关联:issue #519、master `fb130d184`、`docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md`、CI run 3083。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 29b6c172d..182e4656a 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -55,6 +55,8 @@ Windows 下的移动壳 smoke 通过 Node 启动从当前 workspace 包解析出的 Expo/EAS CLI,不直接 `spawnSync('npm.cmd')`;保留原配置与导出断言。具体入口和警告清理边界见本地开发运维文档。 +AGC 的纯桌面/生产接入按模块排除测试编译,共享实现与受测入口保持可测试;拆分后同时检查普通目标和测试目标,并保留原有行为断言。命令注册与退出接线的源码检查跟随真实模块位置更新,不能因移动文件而漏检;不通过全局允许死代码或虚假调用消除告警。具体边界见开发运维文档“编译告警的保留边界与待优化项”。 + 提示词外置变更运行 `runtime_prompt_bundle_build` 与 `prompt_source_boundaries` 两个 Rust 集成测试,验证编译期文本、目录登记和源码边界;现有 `agc-rust-shard-1` 本地/CI 入口先执行这组检查,再运行分片单测。 提示词测试验证实际请求中的片段来源、动态参数和工具结构;措辞不作为逐字契约。已有行为测试覆盖的限制不再另设整段文案检查。Direct 回合测试复用生产的消息转换和文件投影函数,不维护仅供测试调用的回合编排副本。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index eaa5c4bee..817cf6a64 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -9,6 +9,13 @@ - **处理**:线上只收敛 SPA allowlist(12 条,含 `/profile`)并补 `error_page 404 /404.html`;host-only 块收进 `deploy/nginx/snippets/genarrative-host-extras.conf`,由主模板 include、由 `Genarrative-Server-Provision` 安装。退役路由不进白名单,也不需要显式 404 配置,落 `location /` 即得品牌 404(非 HTML 客户端保持纯 404)。 - **判据/取证**:改前 `grep -c profile /etc/nginx/conf.d/genarrative.conf` 为 0;改后 `curl -s -o /dev/null -w '%{http_code}' https://www.genarrative.world/profile` 为 200(正文 sha 与 `/` 一致),`sudo nginx -T | grep -c 'editor/canvas|profile|project'` 为 1,`match3d` 计数为 0。 - **长期口径**:任何 SPA/路由白名单改动都要同时核对**生效配置**(`sudo nginx -T`)与仓库模板;线上主站 vhost 只由模板 + `genarrative-host-extras.conf` 生成,不再手工追加路由。snippet 内的 location 不在 Pingora 路由矩阵覆盖范围,Pingora 接公网 443 前要单独确认这些 host-only 路径。 +## 2026-09-30 构建期 staging 撞上不装 npm 依赖的 Linux 门禁:AGC 壳 Rust lane 全红 + +- **现象**:`Project CI` 的 AGC 壳 Rust 三条 lane(`npm run check:native-shells:agc-rust-shard-*`)在 `fb130d184` 之后全部失败,日志只有 `error: failed to run custom build command for genarrative-ai-game-creator-shell` 与 `thread 'main' panicked at build.rs:65:28: Claude Agent SDK 缺失;请先执行 npm ci`(run 3083 / job 17521 实测,1 分钟即失败)。 +- **原因**:Claude Agent SDK sidecar 的 staging 写在 `build.rs`(构建期 `remove_dir_all` + 从 `node_modules/@anthropic-ai/**` 复制),而这三条 lane 按设计**不装 npm 依赖**(`scripts/project-ci-workflow.test.ts` 的 `jobsWithoutNpmInstall` 显式允许它们没有 `node_modules`),构建脚本一跑就必 panic。同一批改动还把 `resources/claude-agent` 映射进**基线** `tauri.conf.json`:`tauri-build` 会把 `bundle.resources` 的每个路径拷进 target,缺失即 fail(`tauri-utils` 的 `ResourcePathNotFound`),所以即使绕开 panic,Linux 也会在资源解析处再红一次。 +- **处理(现行口径)**:随包资源一律由准备步骤在 `tauri dev|build` 之前 staging,`build.rs` 只读校验(sidecar 走声明 section `claudeAgent` + `scripts/prepare-bundled-resources.mjs`);平台专属资源只允许出现在 `tauri..conf.json`,基线 `tauri.conf.json` 里不得出现 `resources/**`——基线同时服务不产出客户端包的 Linux,`check-config.mjs` 已加该守卫。 +- **判据/取证**:`node --test apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs`、`node apps/ai-game-creator-shell/scripts/check-config.mjs`;Linux 侧判据是三条 AGC Rust lane 转绿且构建期不再出现 `Claude Agent SDK 缺失`。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/build.rs`、`apps/ai-game-creator-shell/scripts/{prepare-bundled-resources.mjs,check-config.mjs}`、`apps/ai-game-creator-shell/src-tauri/{tauri.conf.json,tauri.windows.conf.json,tauri.macos.conf.json}`、`.gitea/workflows/project-ci.yml`、CI run 3083。 ## 2026-09-30 Jenkins release 渠道环境污染 AGC 构建单测 @@ -24,7 +31,7 @@ - **处理(本次)**:`sanitize_error_context` 改为 `split_inclusive('\n')` 逐段处理、原样保留行终止符(CRLF 仍归一成 LF,私钥块整行吞掉的旧口径不变)。逐段脱敏因此与整段脱敏同形,Markdown 结构不再依赖 delta 的切法。 - **仍未修**:`/` 开头的 delta 片段被判成绝对路径(上面 ②)。要修必须把「上一个字符」带进边界判定(正文增量可从 `direct_project_history` 的累计文本取,思考增量目前没有累计器),属于跨 delta 状态的取舍,本次未一并改。 - **判据/取证**:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell direct_thread_delta`——新增 `direct_thread_delta_sanitization_preserves_line_breaks` 钉住「逐段脱敏 == 整段脱敏」,去掉 `split_inclusive` 即红;真实文本的回归用渲染侧夹具复核(修复前 table/li/h2 全 0,修复后与整段脱敏一致:1 个 table / 3 个 th / 4 个 h2)。 -- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/generation/prompt_context.rs`、`.../agent/direct_thread_wire.rs`、`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts`、`#384`。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/generation/prompt_context.rs`、`.../agent/thread_manager/wire.rs`、`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts`、`#384`。 ## 2026-09-29 Game Agent 读工具被项目相对路径规则拦住 @@ -120,6 +127,23 @@ - **leader 卡死的兜底**:闸门只有 follower 的有界等待(60s),若提权子进程真的挂死(`Start-Process -Wait` 无超时),`leader_deadline`(5 分钟)之前该 key 一直被占住,之后新调用会接管并按新 leader 执行;被接管后旧 leader 迟到的结果按令牌丢弃,不会覆盖接管者。`clear_game_creator_acl_elevation_denials` 只清「被拒绝」记忆,不清理 running。 - **关联**:`src-tauri/src/acl_repair_gate.rs`、`src-tauri/src/config.rs`、issue #498。 +## 2026-09-29 随包资源的编译产物摘要不可复现,且准备步骤与应用构建共用同一输出路径 + +- **摘要不可复现**:同一 source / feature / profile / target 连续构建的 `cocos-editor-bridge` payload 摘要不同(除 PE `TimeDateStamp` 外还有 RSDS GUID 等 22 字节差异),所以「与迁移前逐项一致」只能对**源码派生物**(JS/HTML/JSON/license/notice,逐字节比对)、**.NET publish 产物**(Unity helper 跨两次重新发布逐字节一致)和**命中工具链内部缓存的产物**(Godot 走 `buildId` 早退,不重链)成立。核对打包一致性时不要用编译产物的 sha256 判回归,改比路径集合 + 导出面(`DllMain`、`cocos_editor_bridge_bootstrap_source`)+ 源码派生物摘要。 +- **共用输出路径**:准备步骤的 `cocos-bridge-build` 用 `cargo build -p cocos-editor-bridge --features windows-injection`,而应用构建带的是 `windows-bootstrap + windows-injection`(`cocos-editor-injection` 的闭包),两个单元写同一个 `target///deps/cocos_editor_bridge.dll`。后构建的单元覆盖先构建的产物时,准备步骤的候选查找会取到「上一次遗留的另一个单元」,交替构建还会多一次重链。要改就从这里改:让准备步骤用独立 target 目录,或与应用的 feature 集对齐。 +- **验证方式**:`runTauriBuild`(`scripts/build-release.mjs`)+ `--bundles nsis`,再 `7z x` 解包比 `plugins/**`;准备步骤连续三次复跑要求 `resources/plugins` 的 32 个文件内容与 mtime 全不变。 + +## 2026-09-27 随包资源的写入方按产物来源分界:源码派生直接复制,需工具链的先由准备步骤产出 + +- **写法**:新增随包内容先判断来源——能从仓库源码复制就写进 `build_support/package-layout.json` 的 `subdirectories`(`origin: source`);需要外部工具链或同一次 cargo 构建才能产出的,写成 `origin: prepared` / `libraryStaging` / `nativePayloads`,并在 `plugins.prepareSteps` 里声明要跑的程序、工作目录、指纹与必需产物——**不要写进构建脚本**(构建脚本自 M3 起只做只读校验,不再生成任何随包资源)。 +- **校验口径**:`origin: source` 的内容在构建期会与仓库源码逐文件比对(插件清单 + 逐文件 sha256 + 整树符号链接),手改这部分会被 `cargo build` 直接拒绝;`origin: prepared` 只查存在性(Godot 随包库额外跑 `godot_bundle::validate`),手改 prepared 产物不会被拒,要改就改准备步骤的来源或声明。 +- **准备步骤指纹**:声明了指纹的步骤(Unity)命中后不会重跑工具链,改 `plugins/**` 源码即失效;指纹戳文件(`publish/win-x64/.agc-source.sha256`)删掉只会多跑一次构建。`resources/plugins` 由准备步骤拥有,不要手工往里放文件。 + +## 2026-09-27 AGC 随包资源的布局只能改声明文件,生成物由门禁锁死 + +- **现象**:直接编辑 `apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs`,或另写一份组件白名单,`npm run agc:typecheck`(链内含 `npm run agc:bundled-resources:check`)会立刻失败并报「随包资源声明与 Rust 常量不一致」。 +- **正确做法**:改 `build_support/package-layout.json`,运行 `npm run agc:bundled-resources:sync` 重新生成;改布局同时递增 `layoutVersion`(参与准备步骤的缓存 key)。声明里的 `codex.version` 必须与应用锁定的 `@openai/codex` 一致,门禁会对照 `apps/ai-game-creator-shell/package.json` 校验。 +- **边界(M1 完成时)**:准备步骤 `scripts/prepare-bundled-resources.mjs` 尚未接入 dev / 发布入口,`npm run agc` 仍由构建脚本 staging;构建脚本当前既写资源又做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可只跑校验。构建脚本重建 `resources/plugins` 时会整体删除该目录,所以插件侧的准备步骤清单要等 M2 接管写入后才成立,插件目录现在只校验必需组件与符号链接。 ## 2026-09-24 模型输出的围栏会粘在正文行里:聊天 Markdown 必须先归一化再解析 - **现象**:AGC 对话里代码块解析错位——引言行被当成代码渲染(`…实现细节(game.js):```js`),或者代码块收不住、把后面的正文一起吞进去(`… return centerOn(projection); }````)。文本本身「看起来没问题」,容易被当成渲染器坏了。 @@ -2984,9 +3008,9 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`、`sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。 - 原因:环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper,但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`,sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时,Cargo 的 `sccache rustc -vV` 可能先超时。 -- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 在 Windows 下限时执行真实 wrapper 探测 `sccache rustc -vV`,成功才启用 sccache,缺少命令、daemon 启动超时或 wrapper 返回非零时立即给 Rust 子进程注入空 wrapper,回退到直接 rustc,避免损坏的 daemon 阻断启动;显式设置的非 sccache 自定义 wrapper 会被保留。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。 +- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 在 Windows 下限时执行真实 wrapper 探测 `sccache rustc -vV`,成功才启用 sccache,缺少命令、daemon 启动超时或 wrapper 返回非零时立即给 Rust 子进程注入空 wrapper,回退到直接 rustc,避免损坏的 daemon 阻断启动;显式设置的非 sccache 自定义 wrapper 会被保留。`npm run agc` 的 Tauri Cargo 原先直接继承启动器环境,用户级 `~/.cargo/config.toml` 的 `rustc-wrapper` 会在这里生效并复现同一故障(表现为 `failed to run rustc to learn about target-specific information`,AGC 前端与配套后端已经起来、只有 Tauri 客户端退出);现在 `start-tauri-dev.mjs` 在启动 Tauri CLI 前调用 `scripts/dev.mjs` 的 `buildLocalRustProcessEnv`,把两个 wrapper 变量显式写进子进程环境——空环境变量同样能覆盖 Cargo 配置文件里的 wrapper,不能只依赖「本机没配 sccache」。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。 - 验证:`rustc -Vv` 能输出版本;本地 `npm run dev` 能完成 `spacetime publish`、`api-server` `/healthz`、主站 Vite 和后台 Vite 启动;冷启动后原始 `cargo check -p api-server` 和 `cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明原始 Cargo/Jenkins 路径仍可使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。 -- 关联:`scripts/dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 +- 关联:`scripts/dev.mjs`、`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 ## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本 @@ -6090,7 +6114,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现状(正确)**:`src/services/appUpdate.ts` 的 `installAppUpdate` 先 `await update.downloadAndInstall(...)`、成功后才清空待装更新并 `restartAppAfterUpdate()`;失败时保留待装更新,重试走同一条链路。 - **判据**:`apps/ai-game-creator-shell/tests/appUpdate.test.ts` 新增「签名校验失败时拒绝安装、不重启进程,并保留待装更新供重试」——插件抛 `signature verification failed` 时断言 ①错误原样上抛 ②`restart_agc_app` 未被调用 ③再次安装仍会走插件调用并在成功后重启。变异验证:把 `restartAppAfterUpdate()` 挪到 `await` 之前,该用例立即以 `expected "spy" to not be called with arguments: [ 'restart_agc_app' ]` 变红。 - **边界**:真正的验签与临时文件清理都在官方插件原生实现里,本地只能证明"客户端不把失败当成功",真机安装闭环仍需已发布包与真实设备。 -- **顺带记一条环境陷阱(2026-09-28 已修)**:`apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/` 下曾有两个 codex 二进制——`bin/codex.exe` 是**真正被解析**的那份(0.155.1),而包根目录那份 `codex.exe` 是 0.147.0 的旧残留(tauri 的 Windows 资源映射只引用 `bin/` 等路径),检查都查不出来,却会让本地核对误判「应用跑的是 0.147.0」。根因是 `src-tauri/build.rs` 的 `stage_codex_target()` 只按布局拷贝、从不清理目录,旧布局的组件会永久留在随包资源目录里。现在加了 `prune_stale_codex_components()`:拷贝前删掉不在本轮布局、也不在 `manifest.json`/`NOTICE.md` 白名单里的文件并收掉空目录;实测重建后根目录 `codex.exe` 被清掉、六个声明组件与清单/声明保留。 +- **顺带记一条环境陷阱(2026-09-28 已修)**:`apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/` 下曾有两个 codex 二进制——`bin/codex.exe` 是**真正被解析**的那份(0.155.1),而包根目录那份 `codex.exe` 是 0.147.0 的旧残留(tauri 的 Windows 资源映射只引用 `bin/` 等路径),检查都查不出来,却会让本地核对误判「应用跑的是 0.147.0」。根因是构建脚本只按布局拷贝、从不清理目录,旧布局的组件会永久留在随包资源目录里。**现行口径(2026-09-30 起)**:随包资源改由准备步骤整目录原子替换(`scripts/prepare-bundled-resources.mjs` 的 `stageAtomically`),旧布局残留随替换消失;构建脚本只剩只读校验,遇到白名单外的文件会立即失败,所以「本机留着旧组件」最多表现为一次可读的失败,不会再静默随包。(2026-09-28 加的构建期 `prune_stale_codex_components()` 已随 M3 退役,实现不再存在。) ## 2026-09-29 Vite dev 冷启动会让 web E2E 的首个 goto 超时,别当成页面回归 @@ -6172,3 +6196,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现象**:模型目录把回合路由到 `cc`,本地 `game-creator.config.json` 的 `llm.apiKey` 为空时,Claude Agent SDK 返回失败终态;界面只显示“执行通道未能建立或已断开”。 - **根因**:Claude sidecar 只从 `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` 或本地 `llm.apiKey` 读取认证,没有复用已登录的 AGC 平台会话;同时失败终态解析丢弃了上游错误摘要。 - **处理**:官方模型且未启用自定义目录时,将当前平台会话令牌仅注入 sidecar 子进程环境;保留最多 512 字符的 Claude 终态错误摘要,继续由统一诊断层脱敏,避免凭据落盘。 +## 2026-10-01 AGC 首页把 Web 预检错误与 Tauri IPC 错误合并,造成无法诊断的生成阻拦 + +- **现象**:用户在首页点击「做游戏」后看到「Web 游戏环境预检未通过,请检查 Node/npm 或浏览器」,但同一安装包的 `--environment-check` 可能已经返回 `status=ready`;首页仍会阻止自动命名、建项和首次生成。 +- **根因**:`homeWebPreflight.ts` 原先对 `preflight_web_game_creation` 的所有异常统一抛出 `HOME_WEB_PREFLIGHT_FAILURE`,调用方 `useHomeProjectCreation.ts` 的两层 `catch` 又再次覆盖原始错误。于是宿主真实预检码、Tauri IPC 断连、命令未注册和 WebView `Failed to fetch` 都显示成 Node/npm/浏览器故障。 +- **处理**:保留预检失败关闭语义,但提取 Rust 返回的安全错误码;对无错误码的 Tauri 调用故障显示独立 IPC 提示,并仅对这类瞬态调用失败重试一次。首页调用方必须透传该安全提示,不能重新覆盖为通用文案。 +- **验证**:`apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx` 覆盖 ready、具体宿主错误码、IPC 失败、无错误码 blocked 和 planning 绕过;同一 Windows 安装包 CLI 实测 Node `v22.22.2`、npm `10.9.7`、Edge desktop/mobile PNG 均通过。 +- **关联**:`apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx`、`apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs`。 diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md new file mode 100644 index 000000000..6a6f157bd --- /dev/null +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -0,0 +1,225 @@ +# AGC 随包资源 staging 归位技术方案 + +状态:待评审(方案草案,评审通过前不进入实现) +日期:2026-09-26 +范围:AGC 客户端(`apps/ai-game-creator-shell`)随包资源的生成、校验与打包链路 +关联:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md` + +## 1. 一句话目标 + +把「随包资源」从 build script 的**产物**改回它的**输入**:由准备步骤在 `tauri dev|build` 之前一次性 staging,`build.rs` 退化为校验者,构建期不再向 `src-tauri/resources/**` 写任何文件。 + +## 2. 目标与非目标 + +### 2.1 目标 + +1. `build.rs` 的输入集合里不再包含任何「本次构建会写」的文件,`cargo` 在源码不变时稳定 fresh。 +2. Tauri dev 的文件监听不再因为 staging 变更重启 `cargo run`(macOS 与 Windows 一致,不依赖 `.taurignore` 兜)。 +3. staging 与上游版本的绑定可验证:版本、平台、逐文件摘要由校验器 fail closed 检出,不会静默发旧二进制。 +4. 打包产物内容与当前口径一致(三份 tauri 配置的 `resources` 映射与包内资源门禁不变)。 +5. 删除症状层补丁(整目录重建的规避、为绕开自触发而加的 `.taurignore` staging 条目)。`prune_staging`、`STAGED_*` 一类命名只出现在未合入的症状层补丁里,主线从未有过,不需要清理。 + +### 2.2 非目标 + +1. 不改发布渠道、版本号机制、签名与上传流程。 +2. 不改运行时资源解析顺序与完整性校验语义(`codex_cli.rs`、`plugin_host.rs`、`environment_check.rs`、`editor_adapters.rs` 保持行为)。 +3. 不统一 `server-rs` 与 `src-tauri` 两个 workspace,不改 target 布局。 +4. 不为 Linux 增加客户端产物(Linux 上五条 staging 全为 no-op,见 §3.6)。 +5. 不改 Windows/macOS 之外的平台支持面。 + +## 3. 现状与证据 + +### 3.1 build script 目前负责五条 staging + +`apps/ai-game-creator-shell/src-tauri/build.rs` 的 `main()` 前段依次执行(`build.rs:225-229`): + +| # | 步骤 | 动作类型 | 平台门槛 | 目标路径 | +|---|---|---|---|---| +| 1 | `stage_bundled_codex_cli` | 纯复制(内容+权限比对) | Windows / macOS | `resources/codex/**` | +| 2 | `prepare_unity_editor_helper` | **外部工具链**(`powershell -File build.ps1`,需 .NET 10 SDK + VS C++ x64) | Windows + unity feature | `resources/plugins/agc-unity-editor/**` | +| 3 | `prepare_godot_editor_extension` | **外部工具链**(`powershell -File build.ps1`,需 CMake ≥ 3.25 + VS17 2022 + Python 3) | Windows + godot feature | `resources/plugins/agc-godot-editor/native/gdextension/**` | +| 4 | `stage_plugin_workspace` | 复制 + 清残留 | Windows / macOS | `resources/plugins/**` | +| 5 | `stage_cocos_editor_payload` | 复制(同一 cargo 构建产出的 cdylib) | Windows | `resources/plugins/agc-cocos-editor/native/payload/**` | + +(Prompt Bundle 生成代码写 `OUT_DIR`,属正确形态;Node 运行时已由 `scripts/stage-node-runtime.mjs` 在发布前一次性 staging,是本次改造的既有先例。) + +### 3.2 这些写入为什么会让 build script 永远失效 + +`tauri-build` 会对 `bundle.resources` 里的**每个文件**发 `cargo:rerun-if-changed`,并把它拷进 target(`tauri-build-2.6.3/src/lib.rs:88-93`)。我们的三份 tauri 配置把 `resources/codex/**` 与 `resources/plugins` 列进了 `bundle.resources`(`tauri.windows.conf.json:7-15`、`tauri.macos.conf.json:8-22`;基线配置故意不含,见 `check-config.mjs:1377-1383`)。 + +于是「构建期写的文件」与「build script 声明的输入」是同一批: + +```text +staging 写 resources/** → tauri-build 把同一批文件登记成输入 → mtime 变化 + ↑ ↓ + └────────────── cargo 判 build script stale,重跑脚本 ←──────┘ +``` + +判据是「输入文件 mtime 比 build script 的输出新」,与目录位置无关:把 staging 搬到 `target/` 也躲不开——登记的是配置里写的那些路径,只要构建期写它们,照样自触发。所以关键不是「产物放哪」,而是「**构建期有没有人写这些被登记的文件**」。 + +### 3.3 已发生的故障 + +| 症状 | 范围 | 观察证据 | +|---|---|---| +| 每次构建都重编主 crate(41–87 秒,从不变 fresh) | Windows + macOS(Linux 上五条 staging 全为 no-op,不受影响) | `CARGO_LOG=cargo::core::compiler::fingerprint=info cargo build --no-default-features` 打印 `stale: changed "resources/codex/mac-native/darwin-arm64/NOTICE.md"`,且 `.fingerprint/**/run-build-script-build-script-build.json` 的 `RerunIfChanged.paths` 含 staging 目标路径 | +| `npm run agc` 反复 `Rebuilding application`,客户端起不来 | 仅 macOS | dev 日志一轮 28 次以上不收敛;原因是被重写的 `resources/codex/mac-native/**` 落在监听范围内且未被忽略 | +| `remove_dir_all` 抛 `DirectoryNotEmpty`,整次启动中断 | 并发重建时 | `清理 macOS Codex staging 失败` | + +### 3.4 三轮症状层修复都没有收口 + +| commit | 日期 | 修的是什么 | 结果 | +|---|---|---|---| +| `299877b19` | 2026-09-11 | 插件资源改成「内容变化才落盘」+ 引入 `.taurignore` | 只覆盖插件路径,且挡不住 cargo stale | +| `710d6dddf` | 2026-09-23(PR #487) | Windows 上「权限一致就不写元数据」 | 只减少一类写入,未解决整目录重建 | +| 本次未合入的 B 改动 | 2026-09-26 | 全部写入改幂等 + 按布局清残留 + 补 `.taurignore` | 实测可行(第二次 `cargo build` 0.25 秒 fresh),但规则靠人守:新增一条写 `resources/**` 的步骤漏改即回退(godot/cocos/plugin.json 正是三个漏点) | + +结论:**只要 build script 继续写这些受跟踪路径,就必须持续为每一处写入维护「无改动不写」,成本随写入点增长**;这是结构问题,不是疏漏问题。 + +## 4. 方案设计 + +### 4.1 原则 + +build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要「由本仓库生成、再随包分发」的内容,都由 `tauri dev|build` 之前的**准备步骤**生成,build script 只校验。 + +### 4.2 目标形态 + +```text +准备步骤(新,唯一写入方) build.rs(退化为校验者) + fetch/校验上游 → 生成到 staging 目录 只读 resources/** → 校验 manifest/hash/版本/平台 + → 原子替换到 resources/** → 不写任何随包资源 + → 写 manifest.json(schema 化) + ↑ ↑ + dev: start-tauri-dev 内、spawn tauri 之前 release: build-release/build-macos-ci 内 +``` + +### 4.3 准备步骤的合同(必须成立的行为) + +1. **调用时机**:`tauri dev` / `tauri build` 之前完成;任何入口都不得依赖 build script 兜底生成。 +2. **缓存与幂等**:以「上游 lockfile `resolved` + `integrity` + 布局版本 + 目标三元」为 key;命中且 manifest 校验通过的 staging 目录不重写任何文件(避免把「产物」变成「每次构建都变」的新源头)。 +3. **原子性**:写入 staging 临时目录后 rename 替换;不得出现半成品目录(杜绝并发下的 `DirectoryNotEmpty`)。 +4. **所有权**:只允许替换由本工具创建并带 manifest 的目录;遇到非本工具目录、符号链接、越界路径必须 fail closed(沿用 `stage-node-runtime.mjs` 与 `prepare-macos-codex.mjs` 既有判定)。 +5. **清理语义**:准备步骤负责删除本轮布局不再产出的残留(否则旧组件会继续被打包),删除范围限于自己的 staging 目录,不得触碰受版本控制的文件(例如 `resources/codex/win-x64/NOTICE.md`)。 +6. **失败语义**:上游缺失、integrity 不匹配、目标平台不支持、外部工具链缺失 → 立即失败并给出可执行提示;不允许「跳过生成、继续打包」。 +7. **可观测**:输出一行汇总(命中缓存 / 重新生成 / 跳过原因),供本地与 CI 排障。 +8. **并发**:不做并发支持(无实际场景)。同一 staging 目录的并发调用未加锁,会以可读错误失败并保留已生成的 staging 目录;需要并发时由调用方外部串行化。 +9. **实际构建目标**:资源准备与 Cargo 必须使用同一目标和 feature 集。正式发布使用发布目标;无显式 `--target` 的 `--no-bundle` 使用宿主平台,Windows/macOS 仍须准备资源,Linux 编译 smoke 不准备桌面平台专属资源。feature 从最终 Tauri/Cargo 参数解析,不能被开发环境变量单独覆盖;dev 启动器转交的应用参数不参与构建参数解析。 + +### 4.4 build.rs 退化后的职责 + +保留: + +1. 读取 `TARGET` 并写 `cargo:rustc-env=AGC_BUILD_TARGET`(运行时定位随包目录依赖它)。 +2. 校验 `resources/**` 与清单一致:平台目录存在、manifest schema/平台/版本、逐文件 sha256、必需组件齐全(缺一即 fail closed)。 +3. 声明**真实输入**:上游源文件、布局表、受版本控制资源(含 macOS 声明文件)的 `rerun-if-changed`;不得声明任何由本脚本或准备步骤生成的文件。 +4. `tauri_build::build()` 与既有 Prompt Bundle、能力与配置校验。 + +删除: + +1. 五条 staging 的写入逻辑(M2 删除了 codex 与插件工作区的写入分支,仍是构建期产物的三处留在 M3)、针对大目录的整目录重建。 +2. 为绕开自触发而加的 `.taurignore` staging 条目与说明(准备步骤在监听启动前完成,不再需要)。 + +### 4.5 资源清单(谁生成、谁消费) + +| 资源 | 生成方式 | 运行时消费方 | dev 是否需要 | +|---|---|---|---| +| `resources/codex/**` | 纯复制(上游平台包 `vendor/`) | `codex_cli.rs`(内置 sidecar 优先,其次 npm 目录、PATH) | macOS dev 只接受真 `.app` 的 `Contents/Resources`,非 `.app` 场景走 npm/PATH 回退;Windows dev 从 exe 同级读取 | +| `resources/plugins/**` | 复制 + 三个编辑器分支的产物 | `plugin_host.rs`(`AGC_PLUGIN_WORKSPACE` → `resource_dir/plugins` → dev 回退仓库 `plugins/`)、`editor_adapters.rs` | dev 有仓库回退,但 cocos/unity/godot payload 仍以随包路径为准 | +| `resources/plugins/agc-unity-editor/**`、`agc-godot-editor/native/gdextension/**` | 外部工具链(Windows 专属) | `editor_adapters.rs` 候选链 | 仅 Windows | +| `resources/node-runtime/**` | 已有:`stage-node-runtime.mjs` | `environment_check.rs`(`agc-node-runtime.v1` 全量 sha256) | 发布与需要随包 Node 的 dev | +| `resources/claude-agent/**` | 纯复制(应用 `agent-sidecar/src/index.mjs` + 上游 `@anthropic-ai/claude-agent-sdk`、`@anthropic-ai/claude-agent-sdk-`) | `claude_code_cli.rs`(`exe_dir/claude-agent/index.mjs`、macOS `.app` 的 `Contents/Resources/claude-agent`) | Windows / macOS dev:准备步骤按声明 staging | +| `design-agent`、`vendor/*` 许可 | 受版本控制 | `design_tools.rs` 等 | 无需 staging | + +### 4.6 入口接线 + +| 入口 | 位置 | 现状 | 改造后 | +|---|---|---|---| +| AGC dev | `start-tauri-dev.mjs`(`runTauriDev` → 预检 → 前端 → `spawnCli`,准备点在前端就绪之后、`spawnCli` 之前) | 无准备步骤,依赖 build script | 在 `spawnCli` 之前调用准备步骤(命中缓存时秒退) | +| Windows 发布 | `build-release.mjs`(`runTauriBuild`,现有 `stageRuntime(target)` 紧邻 spawn tauri) | 只有 Node 运行时走准备步骤 | 同一挂点串上全部 staging | +| macOS 发布 | `build-macos-ci.mjs`(复用 `runTauriBuild`)→ `check-macos-bundle.mjs` | 同上 | 同上 | +| 本机 Rust 门禁 | `ai-game-creator-shell:check:rust:shell`(Windows 上 `cargo test --no-run` 会跑 build script) | 依赖 build script 生成资源 | 校验器在该场景必须能只读通过;需要真实资源的用例沿用既有 fixture,不得依赖本机 staging 产物 | +| `tauri build --no-bundle` | `build-release.mjs` 的 no-bundle 分支(当前跳过 `stageRuntime`) | 不生成 Node 运行时 | 改为:`--no-bundle` 也执行 staging(app 构建本身需要随包资源),只跳过总号发布;校验器在资源缺失时仍然 fail closed | +| CI(Linux) | `.gitea/workflows/project-ci.yml` 的 AGC 分组 | 五条 staging 全为 no-op | 不需要新增准备步骤;分片与 smoke 命令不变 | + +### 4.7 与现有机制的关系 + +1. **已有先例**:`stage-node-runtime.mjs` 已实现 schema 常量、目标平台失败关闭、staging 目录 + rename 原子替换、拒绝覆盖非本工具目录;`prepare-macos-codex.mjs` 已实现 lockfile `integrity` 驱动的下载、缓存与原子替换。准备步骤应复用这两套范式而不是另起一套。 +2. **打包侧不变**:三份 tauri 配置的 `resources` 映射与 `check-config.mjs:1356-1424` 的逐字断言保持不变;`check-macos-bundle.mjs` 对包内 `coding-agent/mac-native`、`game-runtime/node`、`plugins/agc-cocos-editor` 的存在性、架构与 sha256 断言继续作为发布后门禁。 +3. **fail closed 已有兜底**:`tauri-build` 在资源缺失时以 `ResourcePathNotFound` 直接失败;校验器应比它更早、更明确地报错。 + +### 4.8 单一声明与实现形态(M1 定案) + +准备步骤与构建期校验共用一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。 + +| 侧 | 读取方式 | 用途 | +| --- | --- | --- | +| Node 准备步骤(`scripts/prepare-bundled-resources.mjs`) | 直接读声明 JSON | 组件白名单、Codex 上游候选路径、插件随包子目录与跳过规则、平台与 feature 门槛、缓存 key 组成、manifest 序列化 | +| Rust 构建期校验与运行期布局 | 读声明生成的 `build_support/package-layout.generated.rs`(编译期常量) | 只读校验既有产物、运行期定位随包组件(`codex_bundle.rs` 接口不变) | +| 声明门禁 | `scripts/check-package-layout.mjs`(`npm run agc:bundled-resources:check`,已进 `agc:typecheck` 链) | 校验生成物与声明一致,并检查声明自身不变量:目标唯一、`executable` 属于白名单、每个目标恰有一条第三方声明来源、`codex.version` 与应用锁定的 `@openai/codex` 一致 | + +形态选择**混合**:生成归 Node(复用 `stage-node-runtime.mjs` / `prepare-macos-codex.mjs` 的下载、`integrity`、临时目录 + rename 原子替换范式),声明与校验归 Rust(复用 `codex_bundle.rs` / `godot_bundle.rs` 的布局与摘要校验,运行期模块不改公开接口)。理由:Rust 侧没有下载与 lockfile 解析能力(`[build-dependencies]` 无 HTTP 客户端),Node 侧没有 staging 能力;任选单一语言都要迁移另一侧既有资产。Rust 侧刻意不解析 JSON:声明经生成器变成编译期常量,避免运行期解析与生命周期妥协,也让 `&'static` 布局表与现有调用点保持不变。 + +构建脚本自 M3 起只做只读校验(写入分支与 `AGC_SKIP_RESOURCE_STAGING` 开关一并删除),`cargo build/check` 本身就是对既有产物的校验。 + +### 4.9 M2/M3 实况:构建期写入边界 + +「谁写随包资源」按产物来源分界,声明里的 `origin` 字段表达同一口径: + +| 来源 | 例子 | 谁写 | 时机 | +| --- | --- | --- | --- | +| `source`:声明 + 仓库源码即可生成 | `resources/codex/**`、插件工作区的 `src`/`panels`/`skills`/`native/payload` | 准备步骤(Node) | `tauri dev` / `tauri build` 之前 | +| `prepared` / `libraryStaging` / `nativePayloads`:需要外部工具链或同一次 cargo 构建 | Unity `dotnet/publish/win-x64`、Godot `native/gdextension`、Cocos `native/payload` | 准备步骤:先按声明运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …`,再复制产物 | 同上 | + +M2 之后构建脚本只做只读校验;M3 之后它也不再生成任何随包资源(连编辑器分支产物一并交给准备步骤),并在校验阶段确认源码派生内容逐文件一致、已准备产物在位、Godot 随包库通过既有深度校验。因此源码不变时 `cargo` 稳定 fresh(macOS 实测连续三次 `cargo build --no-default-features` 为 0.69 / 0.22 / 0.22 秒),`resources/plugins` 也不再需要在 `.taurignore` 里忽略(两份 staging 条目已删除)。 + +准备步骤的 Windows 侧命令执行(powershell / cargo)只在本机无法验证,验收清单见 M3 里程碑规范。 + +**2026-09-30(合并 master)**:新增随包组件 Claude Agent SDK sidecar(cc 执行模式)沿用同一口径——声明 `claudeAgent` section(上游 SDK 包与平台原生运行时包的目标表、复制跳过规则、锁定版本),staging 由准备步骤整目录原子替换,`build.rs` 只读校验(入口与仓库源码一致、SDK 与原生运行时版本等于声明、白名单外文件)。它的 `resources/claude-agent` 映射放在 `tauri.windows.conf.json` 与 `tauri.macos.conf.json`,**不得**回到基线 `tauri.conf.json`:基线同时服务 Linux(CI 只在那里编译壳 crate 且不装 npm 依赖),`tauri-build` 会因资源缺失直接失败。 + +## 5. 兼容与迁移 + +1. **产物兼容**:包内路径、manifest schema(`genarrative-codex-sidecar.v2`、`agc-node-runtime.v1`、插件 `plugin.json`)不变,安装包内容逐项对得上;升级路径不需要用户侧动作。 +2. **过渡期(已完成)**:M1 让准备步骤与构建脚本产物并存并逐文件比对一致,M2 移除了构建脚本里 codex 与插件工作区的写入分支;剩余在构建期写入的三处(Unity publish 目录、Godot gdextension、Cocos payload)随 M3 归位。删除与新增不跨里程碑混在一起。 +3. **本机残留**:M2 已删除 codex 与插件工作区的写入逻辑与整目录重建;`.taurignore` 的 staging 条目及其原因说明保留到 M3——Windows 上仍有三处构建期写入落在 `resources/plugins/**`,去掉忽略会重新引入监听自触发。`resources/**` 仍保持 gitignored。 +4. **回滚**:准备步骤与校验器保持独立可关闭(例如校验器只读、不写),回滚只需恢复 build script 的写入分支,不涉及数据迁移。 + +## 6. 验收标准与证据 + +| 项 | 判据 | +|---|---| +| 构建新鲜度 | 源码不变时连续两次 `cargo build --no-default-features` 第二次为秒级 `Finished`;IDE 的 `cargo check --all-targets` 同样不重复构建 | +| 无自触发 | `cargo:rerun-if-changed` 输出与 fingerprint 记录里不出现任何 `src-tauri/resources/**` 路径 | +| dev 可用 | macOS/Windows `npm run agc` 在准备步骤后一次成功:`Rebuilding application` 为 0 次、`Running DevCommand` 为 1 次,客户端与 Runner 进程稳定存活 | +| 包内容 | `check-macos-bundle.mjs` 全绿;Windows 安装包内 `coding-agent`、`plugins`、`game-runtime/node` 与改造前逐项一致 | +| 失败关闭 | 上游缺失 / integrity 不匹配 / 清单缺组件 / 目标平台不支持 四类场景各自返回明确错误且不产出包 | +| 幂等 | 准备步骤连续执行两次,staging 目录内容与 mtime 不变(不改动受跟踪输入) | +| 门禁 | `check-config.mjs`、`check:encoding`、`check:doc-index`、`git diff --check`、AGC 相关 vitest 与 Rust 测试全绿 | + +未验证项必须在交付记录中标注(例如 Windows 真机行为、真实上游包下载在受限网络下的表现)。 + +## 7. 风险与回滚 + +| 风险 | 影响 | 措施 | +|---|---|---| +| 忘记调用准备步骤(dev 或某个发布入口) | 资源缺失,打包或启动失败 | 校验器 fail closed + 入口测试断言「spawn tauri 前已调用准备步骤」 | +| staging 缓存 key 不覆盖上游变化 | 静默发旧二进制 | key 含 lockfile `resolved`+`integrity`+布局版本+三元;manifest 校验作为第二道闸 | +| 外部工具链步骤(unity/godot)搬出后顺序变化 | Windows 打包失败 | 准备步骤显式声明工具链前置检查;先在 Windows 上单独验证再合入 | +| 并发调用同一 staging 目录 | 失败可读、不丢 staging | 不加锁也不支持并发:替换失败给出可执行提示并保留 staging 目录,需要并发时外部串行化 | +| 迁移期两套生成并存 | 结果漂移 | 并存阶段以「准备步骤生成结果 == build script 生成结果」逐文件比对作为过渡判据 | + +回滚点:准备步骤上线但校验器未启用前,任一步失败都可直接恢复 build script 写入分支,无需数据迁移。 + +## 8. 里程碑拆分(建议) + +| 里程碑 | 交付 | 停止条件 | +|---|---|---| +| M1 校验器化 | 单一声明 + 生成门禁、`build.rs` 只读校验路径、准备步骤脚本(codex + plugins 两条纯复制路径)、缓存与原子替换 | 校验器在既有 staging 产物上全绿且不改变现有构建行为(写入分支与 `AGC_SKIP_RESOURCE_STAGING` 开关在 M3 一并删除) | +| M2 入口接线 | dev 与两个发布入口调用准备步骤;codex/plugins 的写入分支从 build.rs 移除;`cargo` 新鲜度与 dev 不再重建达标 | 已交付(2026-09-27):macOS 侧 §6 前三行达标(连续 `cargo build` 0.69 / 0.22 / 0.22 秒 fresh;`tauri dev` 全程 `Rebuilding application` 0 次、`Running DevCommand` 1 次);Windows 新鲜度待 M3 归位三处构建期产物后复验 | +| M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `.taurignore` 的 staging 条目与相关注释;文档收口 | 已实现(2026-09-27):三处产物改由准备步骤按声明运行 powershell/cargo 后复制,build.rs 只剩只读校验,两份 `.taurignore` 已删除;已通过第五轮 Windows 真机评审:构建脚本不再写资源(90 个文件 0 变化)、第二次 `cargo build` 1.13s fresh、三分支产物齐备、幂等与 fail-closed 通过;仍待验收的是「安装包内产物路径/sha256 比对」与「三个编辑器分支客户端加载」 | + +里程碑规范与单里程碑实现计划按 [`docs/【协作规范】规范驱动开发工作流-2026-09-12.md`](../【协作规范】规范驱动开发工作流-2026-09-12.md) 另立 `docs/project-memory/plans/` 下的临时文件;本方案是它们的主规范来源。 + +## 9. 未决问题 + +1. ~~准备步骤用 Rust bin 还是 Node 脚本?~~ **已定(2026-09-27):混合**——生成归 Node、声明与校验归 Rust,布局与白名单收敛为单一声明文件。机制、门禁与验证入口见 §4.8。 +2. unity/godot 的外部工具链步骤是否值得搬出 build script(它们本身是构建动作,搬出后需要显式前置顺序)——需在 Windows 上确认收益与风险。 +3. `resources/**` 是否需要继续保留在 crate 内(`bundle.resources` 相对路径解析要求),还是改用生成式配置指向 `target/` 下的 staging:前者改动小、后者更彻底,需与 Tauri 的资源解析规则一起评估。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index f65a82ef2..860ef3572 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -116,6 +116,7 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema - 对客户端刚创建且内容仍匹配可信模板的 Web 脚手架,在正式生成前由宿主执行受控依赖准备和真实 Vite 构建。依赖安装禁用生命周期脚本;不自动安装或覆盖导入/用户修改过的工程。 - 准备凭证的 `ready` 只证明初次环境准备成功,不代表当前游戏已验收,后续正常修改不得因此重新安装。`preparing` 中断恢复必须证明原拥有者已结束且其执行子树已回收;身份或归属未知时保留阻断,不重复执行。 - 输出明确区分“Node/npm 构建能力通过”与“本项目 Vite 构建通过”;任何一步失败保留可行动错误,不降级为预检成功。 +- 首页必须区分宿主预检阻塞与 Tauri IPC 调用失败:Rust 返回的安全错误码可透传到状态栏;无错误码的瞬态 IPC 失败只允许一次有界重试后显示独立的客户端连接故障,不得统一伪装成 Node/npm 或浏览器故障。预检仍保持失败关闭,不得因为展示错误变得可绕过。 - 安装载荷提供相同的安全预检 CLI,验证无系统 Node 的独立运行、缺包/篡改失败关闭。NSIS 解包载荷 smoke 与真实安装器注册流程分开报告,不覆盖当前用户的既有安装。 ### 跑酷固定基线 @@ -1787,6 +1788,9 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 - 清单补充可选 `projectName` 与 `pendingFiles`:名称来自本地 manifest;`pendingFiles` 是本轮失败、延后、并发变动与非策略排除的跳过文件数量。`0` 表示扫描范围已同步;大文件等被跳过不能标成完整。旧清单字段缺失表示完整性未知,维持可读取兼容,不反写旧清单。 - 项目名称或完整性发生变化时,即使文件内容没有差异也要提交新清单;本机索引记录上次已提交的这两个字段。实际客户端下一次正常同步可补齐历史清单元数据;后台只读访问不迁移旧清单。缺失 `pendingFiles` 不能默认成 0,临时跳过原因消失后允许无文件上传的 `partial → ready` 转换。 - 后台增加“项目工程”入口,仅 owner 及拥有 `project-snapshots` 页签权限的管理员可访问。`GET /admin/api/project-snapshots?cursor=&limit=20` 读取私有 OSS 清单并返回 `{items,nextCursor}`;单页最多 100 个,游标由服务端校验,目录与清单读取有界。条目为 `{userId,projectId,projectName,syncRevision,syncedAtMs,fileCount,totalBytes,status}`,状态为 `ready / partial / unverified`,名称缺失时显示 projectId。 +- 后台“按同步时间排序”是主动读取当前渠道全量项目的临时展示操作:从首游标开始,以每次 100 条沿 `nextCursor` 顺序读取,只有全部读取成功后才切换为 `syncedAtMs` 降序、`userId` 字符串升序、`projectId` 字符串升序;不能只排序当前远端页。成功后回到第一页,20/50/100 条分页及上一页/下一页复用浏览器中的完整列表,不再请求远端;“刷新”重新读取全量并回到第一页,“恢复默认顺序”在远端目录第一页读取成功后统一切换列表、排序模式和分页游标;读取失败保留原排序集合、当前页码和本地分页,支持重试。切换渠道、令牌或离开页面销毁临时结果并取消在途读取,不跨会话持久化。 +- 全量读取展示已取得项目数并允许取消;单次最多 200 个远端页、10,000 个唯一项目、60 秒,重复游标或超限明确报错。读取失败、鉴权失败、超时或取消保留原来的列表和分页,不能发布部分数据作为排序完成的结果。并发同步期间重复项目按用户/项目身份合并,保留同步时间较新的一份,相同时间取后读到的清单;该列表是遍历期间读到的数据集合,不承诺 OSS 全局事务快照,后续同步由管理员刷新读取。仍由原接口实施渠道和页签鉴权,不新增 OSS 索引、数据库表、API 参数或客户端迁移。 +- 排序验收覆盖远端多页与空中间页、时间相同的身份排序、本地翻页/页容量、刷新与恢复默认、失败/取消/超时/限额/重复游标、并发重复身份,以及渠道/令牌切换和卸载后的旧响应隔离;证据入口为 `apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx`、后台类型检查和编码/文档索引检查。 - `GET /admin/api/project-snapshots/{userId}/{projectId}/download` 只读取该用户/项目的固定清单与其引用对象,返回 `application/zip` 附件。ZIP 中路径直接使用原始相对路径,不包含 userId、摘要目录或 OSS 前缀;名称使用经过安全处理的项目名和 revision。下载固定本次读到的清单,远端并发回收导致对象缺失则整体失败,不能静默遗漏。 - `partial` 快照下载返回 409;`unverified` 历史快照可导出已同步文件,列表明确显示“完整性未知”,动作称“下载已存文件”。`ready` 才显示“下载完整工程”。ZIP 构建核验每一文件的长度与 fnv1a64 摘要,拒绝穿越、绝对路径、重复/大小写冲突路径、非法项目身份;缺失或损坏整体失败,不返回成功的残缺 ZIP。 - ZIP 使用服务端临时文件并限制并发,不将 2 GiB 工程整体驻留内存;成功、失败、客户端取消均清理临时文件。单文件、总量、文件数沿用上传上限,超限明确拒绝。零字节工程文件可以上传和导出。OSS 凭据与签名不下发浏览器,列表失败保留错误而非伪造空列表。 diff --git a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md index 610b6d5aa..b47f28b1f 100644 --- a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md +++ b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md @@ -4,10 +4,10 @@ 本方案的**卡片表现层**(折叠 / 展开、标题与摘要文案、耗时与时间显示、脱敏、无障碍、样式)仍然是有效契约;**数据来源层**已被 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 取代,边界改为: -- DirectProject 聊天框的工具卡片由**运行态事件 + 项目对话历史**在前端投影生成(`features/project-workspace/directThreadItemProjection.ts`),不再读取 `tool-calls.jsonl`;`read_direct_tool_calls` 命令与 Rust 侧 `read_direct_tool_calls_at` 回读函数已删除,该文件现在只有 DirectRuntime 的写入。 -- 报文中不再有 `toolCalls` 增量字段与 `GameCreatorDirectTurnUpdateEvent` 这条实时链路:卡片形状由前端从脱敏原始条目生成,事件里只有 `item.started` / `item.completed` / `item.delta`(线上模型见 `agent/direct_thread_wire.rs`,由 ts-rs 导出绑定)。 +- DirectProject 聊天框的工具卡片由**运行态事件 + 项目对话历史**在前端投影生成(`src/view/project-development/chat/conversation/directThreadItemProjection.ts`);工具条目在读取期由 `agent/thread_manager/wire.rs` 统一脱敏与截断,不再有独立的工具调用账本或回读命令。 +- 报文中不再有 `toolCalls` 增量字段与 `GameCreatorDirectTurnUpdateEvent` 这条实时链路:卡片形状由前端从脱敏原始条目生成,事件里只有 `item.started` / `item.completed` / `item.delta`(线上模型见 `agent/thread_manager/wire.rs`,由 ts-rs 导出绑定)。 - 卡片身份只有一个 `itemId`(工具条目在 `project.jsonl` 里带的两个 id 已在 Rust 边界归一),前端卡片形状是 `Omit`:聊天卡片不再有回合身份。 -- 下面「### 1. 工具调用条目」「### 2. 实时事件」「### 3. 回读命令」三节描述的是 DirectRuntime 自己的账本(`tool-calls.jsonl` 的写入形状与脱敏规则仍然有效,DirectRuntime 保留),**不再是 DirectProject 聊天框的读路径**;「### 4. 前端合并与渲染」中按 `turnId` 归并、按 `turn-stream.jsonl` 的 `seq` 交替的规则已作废,改为按事件顺序 + 历史文件顺序投影。 +- DirectRuntime 曾经的 `tool-calls.jsonl` / `turn-stream.jsonl` 账本、写入器与进度事件已整体删除(2026-09-30,issue #553);本方案原先描述这些落盘与事件契约的「工具调用条目」「实时事件」「回读命令」三节一并移除,改为按事件顺序 + 历史文件顺序投影。 ## 一句话交付 @@ -44,60 +44,24 @@ - 同一用户消息从本地发送转为正式条目时必须保留原发送时间,不能因去重丢掉该时间而改用启动应答后的观测时间。回合明确结束时,即使当前 live 集合为空,也应将终态边界关联到已知的本轮用户条目;不得仅按“历史最后一项”猜测或向无关旧回合补时间。 - 生命周期事件可携带已有用户条目的 `userItemId`,用于把时间边界精确关联到同一条历史消息(不产生新的回合 ID 或持久化字段)。恢复时该关联随 Thread Manager 原事件重放;带旧用户身份的终态不能收口另一条新请求。缺少身份的旧事件不据时间猜测归属。 -## 背景与现状(已核实) +## 背景与现状(2026-09-14 立项时点) + +> 以下记录立项时点的现状,用来解释这次改造的动机;其中「工具调用不留痕」「没有结构化工具调用」等结论都已被后续实现与 2026-09-16 修订取代,**当前状态以文首修订与 ADR 为准**。 - 数据来源:Codex app-server 会推 `item/started` / `item/completed`,item 里带完整信息(`commandExecution.command`、`fileChange.changes[].path` 等)。 - 现状投影:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs` 的 `direct_codex_item_intermediate_text()`(约 791 行)把 item **压成一行中文文本**(`正在执行命令:xxx` / `正在写入文件:xxx`),经 `DirectCodexTurnObservation::IntermediateText` 下发。 -- 前端事件:`GameCreatorDirectTurnUpdateEvent`(`src/app/types.ts:1090`)只有 `projectPath / turnId / sequence / status / activity / accumulatedText / updatedAt`,**没有结构化工具调用**。 +- 前端事件:`GameCreatorDirectTurnUpdateEvent`(该事件已随 issue #553 删除)只有 `projectPath / turnId / sequence / status / activity / accumulatedText / updatedAt`,**没有结构化工具调用**。 - 历史持久化:`.agent/conversations/project.jsonl` 现在只写 message 条目(实测 61 条全是 message),工具调用不留痕。 - 历史回读:`read_direct_project_chat_history_at()`(`direct_project_history.rs:575`)只把 `role ∈ {user, assistant}` 且有文本的条目投影成 `LocalConversationMessageRecord`,**形状上装不下工具调用**。 - 结论:要做成卡片必须同时改「采集 → 传输 → 持久化 → 回读 → 渲染」五段,纯前端做不出来。 ## 契约(实现必须照此,不得自行改形状) -### 1. 工具调用条目(采集与持久化形状) +工具卡片的两处数据来源是 `.agent/conversations/project.jsonl` 的原始条目与 Thread Manager 的运行态事件;Rust 出口只做挑字段、脱敏、截断,卡片形状、可见性、合并与计时全部由前端投影完成。 -新增独立历史文件:`/.agent/conversations/tool-calls.jsonl`,一行一条,行信封与既有历史一致: +### 前端合并与渲染(回合唯一归属,连续工具成块) -```json -{ "type": "tool_call_item", "payload": { "schemaVersion": "agc-tool-call.v1", "id": "...", "turnId": "...", "kind": "command|file_change|mcp_tool|web_search|context_compaction|other", "title": "执行命令", "summary": "npm run build", "status": "running|completed|failed", "detail": { "command": "...", "output": "...", "changes": [{ "path": "game/src/x.ts", "kind": "add|update|delete" }] }, "startedAt": 0, "updatedAt": 0 } } -``` - -- `id`:Codex item 的 id;同一 item 的 `started` 与 `completed` 必须落成**同一条**(按 id 幂等 upsert,不允许写两行)。 -- **状态单调**:同一 `id` 的每条快照按 `updatedAt` 合并落盘——`updatedAt` 更旧的快照不得覆盖更新的 `status` 与 `updatedAt`。逐条快照落盘与回合末整批落盘两条路径会并发竞争,后到的旧快照不能把已经 `completed` / `failed` 的卡片打回 `running`;`updatedAt` 相同时终态优先;`startedAt` 取最早的非零值(`item/completed` 不一定带 `startedAtMs`)。 -- `title` 是折叠态的一行标题,按 kind 固定:`command` → `执行命令`、`file_change` → `编辑 N 个文件`(N = changes 去重后数量)、其余见 kind 枚举。 -- `summary` 是折叠态标题后面的短摘要:命令取命令首行(截断 120 字符),`file_change` 取首个变更路径。`summary` 的每个来源(命令、变更路径、`tool`)都必须先脱敏再落盘。 -- `detail.command` 读取原生命令或 MCP `arguments`,`detail.output` 读取 `aggregatedOutput` / `output` / `result` / `error`;对象格式化为 JSON,先脱敏再各截断到 4000 字符。展开工具行分别显示“输入”“输出”。MCP 摘要保留工具名,不把 JSON 开头的 `{` 当成摘要。状态相同但详情变化也必须更新;完成快照缺少输入字段时保留开始快照的输入。 -- **路径形状**:`detail.changes[].path` 用**项目相对路径**(如 `game/src/x.ts`,分隔符统一成 `/`);项目外的绝对路径落成 `` 占位。任何情况下都不得写出项目根目录本身、用户家目录或绝对路径的原始值。 -- **必须脱敏**(落盘前统一走 `agent/direct_tool_calls.rs` 的 `sanitize_detail_text`,顺序:项目路径归一化 → `redact_absolute_path_tokens` → `redact_secret_tokens` → `sanitize_error_context`): - - 前缀型密钥沿用 `redact_secret_tokens`(`sk-…`、`tnr_sk_…`、`ghp_…`、`AKIA…`、`eyJ…` 等); - - 键值型凭据沿用 `sanitize_error_context`(= `redact_secret_tokens` + `redact_error_sensitive_assignments` + `redact_error_bearer_values` + `redact_error_config_names` 的既有组合),覆盖 `Authorization: Bearer …`、`Cookie: session=…`、`api_key=…`、`client_secret=…`、`token=…` 等形状; - - 含 `--password` / `--token` / `--secret` / `--api-key` 这类敏感 CLI 标志的行按既有 fail-closed 约定**整行**替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致;即使标志后面只是 `$VAR` 占位符也整行替换,占位符本身不会保留); - - 脱敏必须幂等(同一段文本连跑两次结果一致),且不得把未脱敏文本写进 `detail` / `summary`。 - -### 2. 实时事件(新增字段,不改既有字段语义) - -`GameCreatorDirectTurnUpdateEvent` 增加**可选**字段: - -```ts -toolCalls?: DirectTurnToolCall[] | null; -``` - -- 只有在本回合工具调用集合发生变化时才带(不要每个 heartbeat 都重发全量)。 -- 字段**可选**:老版本事件解析路径必须保持兼容(前端拿到 `undefined` 时行为与现在一致)。 -- `DirectTurnToolCall` 与上面 payload 同形(去掉 `turnId`)。 - -### 3. 落盘契约(写侧) - -DirectRuntime 写 `/.agent/conversations/tool-calls.jsonl`;回读命令与 Rust 侧回读函数已随聊天读路径退役删除,下面的语义约束的是**写进文件的行**。 - -- **上限语义**:200 条是「按时间保留最新 200 条」。超出时更早回合的卡片会被**静默丢弃**(老回合卡片会消失),不做分页、不做历史回填;同一 `id` 的多条记录先按 `updatedAt` 合并,再按时间正序裁剪。 -- 历史文件缺失 → 返回空数组,不报错。 -- 单行损坏 → 逐行读字节并逐行解码,跳过该行继续,不整体失败;只有损坏字节与下一行黏成一行(例如写入被截断、缺失换行)时,被丢掉的也只是那**一行**,其后的合法记录必须继续读回(与 Codex item 流一样是"尽力而为"的展示数据,不是业务真相)。 - -### 4. 前端合并与渲染(回合唯一归属,连续工具成块)——已作废,见文首修订 - -- 加载对话时按 `turnId` 归并为唯一回合容器,用户消息保留在该回合前部。有 `turn-stream.jsonl` 时,文本与工具按 item `seq` 交替,连续工具合为一块,遇到文本另起一块;没有流的历史回合才采用“工具块 + 历史正文”。 +- 加载对话时按历史条目进入 `project.jsonl` 的原始顺序投影:用户消息、文本与工具块保持原序,连续工具合为一块、遇到文本另起一块,不按 `turnId` 重新归并、也不再有 `seq` 交替。 - 回合完成后,中间文本及所有工具块统一收进默认关闭的“执行过程”;最终回复及失败提示留在外面。展开后仍按原顺序查看中间输出和工具详情;运行中不使用外层折叠区。用户消息的发送时间从消息自身的历史时间读取,不能拿工具起点补造。 - 实时与回读共用同一投影,正文、工具和耗时不另建实时/未归属渲染出口。先在完整历史按消息身份关联,再分页;禁止按第 N 个工具回合匹配第 N 条用户消息。详情通过当前回合 `callId` 关联;同项目回读与实时增量幂等合并,切项目清空旧状态。完整合同见 [AGC 实施计划](./【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的“DirectProject 回合展示唯一归属”。 - 块 DOM 与交互(对齐 Codex): @@ -146,32 +110,28 @@ DirectRuntime 写 `/.agent/conversations/tool-calls.jsonl`;回读 ## 验收判据(每条都要有可复现证据) -1. 一轮真实回合里,`tool-calls.jsonl` 里同 id 只有一行,`completed` 后 `status` 变 `completed`。 +1. 一轮真实回合里,按项目对话历史投影出的工具卡片同 id 只有一张,`completed` 后状态变 `completed`。 2. 刷新页面 / 重开项目后,工具调用卡仍在原位(不重复、不丢)。 3. 实时回合中卡片状态从 `running` 走到 `completed`。 4. 折叠/展开键盘可达(Tab 到头部按钮、Enter/Space 切换、`aria-expanded` 同步)。 -5. `Read` 到的既有对话(无工具调用)行为不变;老事件(无 `toolCalls` 字段)行为不变。 -6. 脱敏检查:`tool-calls.jsonl` 里不出现 API Key / Token / 绝对用户目录。 +5. `Read` 到的既有对话(无工具调用)行为不变。 +6. 脱敏检查:卡片摘要、输入 / 输出与展开详情的落盘内容中不出现 API Key / Token / 绝对用户目录。 ## 不做项(本次明确不做) - 不做工具调用的重试 / 取消 / 编辑按钮。 - 不做 diff 级展开(只在展开态列文件路径与变更类型)。 - 不做工具输出的完整回放、不做卡片内搜索。 -- 不改 `.agent/conversations/project.jsonl` 的既有格式与注入 Codex 上下文的路径(新数据走独立文件,避免污染模型上下文)。 +- 不改 `.agent/conversations/project.jsonl` 的既有格式与注入 Codex 上下文的路径。 - 不改工具调用之外的聊天渲染(markdown、气泡、滚动)。 - 不做历史工具调用的迁移回填(存量项目没有卡片,属预期)。 ## 涉及文件(实现边界) -- Rust(`apps/ai-game-creator-shell/src-tauri/src/`):`agent/codex_app_server.rs`(item → 结构化采集)、`agent/direct_runtime.rs`(随事件下发 + 回合结束持久化)、`agent/direct_project_history.rs` 或新增 `agent/direct_tool_calls.rs`(upsert 与回读)、`commands.rs`(新增命令注册)。 -- 前端(`apps/ai-game-creator-shell/src/`):`app/types.ts`(新增字段与类型)、`App.tsx`(订阅、累加、加载时合并)、`features/agent-runtime/` 或新增 `features/project-workspace/ToolCallCard.tsx`(卡片组件)、`styles.css`(卡片样式,新样式集中在文件末尾中文注释区块)。 -- 测试:Rust 侧单元测试(upsert 幂等、状态按 `updatedAt` 单调合并、截断、脱敏覆盖与幂等性、项目相对路径形状、非法 UTF-8 损坏行跳过、200 条上限保留最新)、前端 `tests/`(卡片渲染、折叠交互、重开项目后合并、老事件兼容)。 +- Rust(`apps/ai-game-creator-shell/src-tauri/src/`):`agent/codex_app_server/mod.rs`(item → 结构化采集)、`agent/thread_manager/wire.rs`(读取期脱敏、截断与 ts-rs 导出模型)、`agent/direct_runtime/mod.rs`(运行态事件)。 +- 前端(`apps/ai-game-creator-shell/src/`):`app/types.ts`(卡片类型)、`view/project-development/chat/conversation/directThreadItemProjection.ts`(历史条目 → 卡片投影)、`view/project-development/chat/components/ToolCallGroup/`(卡片组件与文案)、`packages/shared`(共享过程样式)。 +- 测试:Rust 侧单元测试(历史条目投影、脱敏覆盖与幂等性、相对路径形状、截断)、前端 `tests/`(卡片渲染、折叠交互、重开项目后合并)。 -## 实施顺序(每步都要能独立验证) +## 实施状态 -1. Rust 采集 + 独立文件 upsert:先只落盘,单元测试证明幂等与截断。 -2. 事件字段下发 + 前端类型:老路径不受影响(回归现有 appSurface 用例)。 -3. Tauri 回读命令 + 前端加载合并:刷新后卡片存在。 -4. 卡片组件 + 样式 + 无障碍:折叠/展开 + 键盘。 -5. 真实回合联调(本地 Tauri 起一轮),截图留证。 +卡片表现层已按本方案落地;数据来源层在 2026-09-16 改为「运行态事件 + 项目对话历史」前端投影(见文首修订),原先的直连工具账本链路已随 issue #553 清理移除。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 4f61f7f79..adb9499c7 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -812,8 +812,9 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 ### `profile_wallet_ledger` - Rust 结构体:`ProfileWalletLedger` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 说明:账号钱包流水表。`created_at` 表示钱包事务实际结算时间,列表先按当前余额反向校验 `balance_after - amount_delta` 的结算链,再以该时间倒序兜底,避免支付回调或退款重放延迟时出现余额顺序倒置;支付平台确认时间继续保存在充值订单 `paid_at`。`metadata_json` 为可选 JSON 对象字符串,旧行缺失时读取层按 `{}` 归一;外部生成扣费 / 退款写入 `externalGenerationJobId`,使退款记录可以追溯到对应 `external_generation_job`。 +- 实际变动为 0 的条目不进账单:写入侧只在余额真的变化时落账,每日免费 / 会员周期换期先按 `previous_balance - expired_points + granted_points`(饱和运算)算出实际变动,变动为 0 时只更新余额状态、不写本表;通用结算入口 `apply_profile_wallet_signed_delta` 在 `amount_delta == 0` 时同样不落账。读取侧 `list_profile_wallet_ledger_entries` 在结算链排序之后、截断 50 条上限之前剔除 `amount_delta == 0` 的存量行,因此历史遗留的 0 变动流水不会出现在 `GET /api/profile/wallet-ledger`,也不占用列表上限。该规则只影响展示与落账,不改变幂等 ledger、余额结算、消费投影和退款语义。 ### `profile_wallet_consumption_total` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 6db558aa9..4f73e66ef 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -123,10 +123,16 @@ AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creato AGC 开发态还会按后台 Web 的端口约定额外拉起 `apps/admin-web`:Linux 用当前用户端口段的 `start + 3` 槽位,非 Linux 以 `3102` 为兼容首选并允许统一漂移,`ADMIN_WEB_PORT` 可显式指定且必须避开已解析的 AGC Vite 端口;设置 `AGC_DEV_ADMIN_WEB=0` 可关闭。后台 Vite 与 AGC Vite 一样由 `start-dev-stack.mjs` 直接持有并随启动器退出收束,不走 `npm run dev:admin-web`——后者会整体重写 `.app/dev-stack.json`,覆盖本次配套后端的归属状态;后台 Web 的端口解析、启动失败或运行中意外退出都只打印告警,不阻断也不连带停止 AGC 客户端与配套后端。前端与配套后端就绪后,启动器会打印一行 `[ai-game-creator-shell] 启动汇总:`,依次给出前端、后端、后台、数据库与 `bgfilter-worker` 的实际地址;端口漂移或默认端口被其它工作树占用时,以这一行为准。 -Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 +Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 默认不主动启用 sccache;只有用户通过 `RUSTC_WRAPPER` 或 `CARGO_BUILD_RUSTC_WRAPPER` 显式配置 wrapper 时才进入处理流程。配置为 sccache 时会限时执行真实 wrapper 探测,成功才使用缓存;未安装、不可执行、超时或两个变量冲突时设置为空值,回退到真实 `rustc`,不阻断启动。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志中的 wrapper 启用、冲突或回退提示。 +`npm run agc` 的 Tauri Cargo 走同一套本地 wrapper 规则:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 在启动 Tauri CLI 前调用 `scripts/dev.mjs` 导出的 `buildLocalRustProcessEnv`,把决定结果显式写进 `RUSTC_WRAPPER` 与 `CARGO_BUILD_RUSTC_WRAPPER`。用户级或仓库级 Cargo 配置里的 `rustc-wrapper`(本地常见为 `~/.cargo/config.toml` 的 sccache)只在环境变量非空时才会被覆盖,所以这两个变量必须由脚本写入而不能留空;否则本机 sccache daemon 状态损坏时,Tauri Cargo 的首次 rustc 探测(`failed to run rustc to learn about target-specific information`)就会中断整个 AGC 启动,而配套后端因为已经在用同一规则而能正常起来。AGC 启动日志出现 `[dev:rust]` 提示即为该规则生效。 + +AGC 随包资源(内置 Codex CLI、插件工作区)的布局与组件白名单只有一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。Node 侧准备步骤直接读它,Rust 侧读由 `node scripts/check-package-layout.mjs --write`(仓库根 `npm run agc:bundled-resources:sync`)生成的 `build_support/package-layout.generated.rs`;门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,两者不一致直接失败。改布局只能改声明文件再同步生成物,不要手改生成文件,也不要另写第二份白名单。准备步骤是 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`:写临时目录后原子替换、命中缓存不写任何文件、只替换本工具产物、失败即退出并给出可执行提示;其用例为 `npm run agc:bundled-resources:test`。内置 Codex CLI 的上游平台包来自仓库根 `npm ci`,缺失时工具会直接提示重新安装。构建脚本对既有随包产物做只读校验(不再有写入分支,因此也没有跳过写入的开关)。 + +随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,并传 `profile: 'release'`,`tauri build --no-bundle` 也会执行随包资源 staging(app 构建本身就需要这些资源),只跳过总号发布。**构建脚本完全不生成随包资源**(源码派生内容逐文件比对、已准备产物查存在性、Godot 随包库跑既有深度校验),源码不变时 `cargo build` 稳定 fresh。需要外部工具链或同一次 cargo 构建才能产出的内容(Unity publish 目录、Godot gdextension、Cocos bridge dll)也由准备步骤按 `build_support/package-layout.json` 的 `prepareSteps` 先运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …` 再复制;这些步骤按内容指纹跳过未变化的情况。`resources/plugins` 由准备步骤拥有:不要手工往里放东西,准备步骤会按仓库 `plugins/` 与声明重建;dev 构建下客户端读的是 `target/debug/plugins/**`(Tauri 在 debug 配置下把随包资源拷到那里),所以改完资源要重跑准备步骤而不是手动改 `resources/`;`.taurignore` 的 staging 条目已删除(构建期不再写该目录)。 + ### 本地 Rust 构建缓存与磁盘上限 `server-rs/Cargo.toml` 和 `apps/ai-game-creator-shell/src-tauri/Cargo.toml` 是两个独立 Cargo workspace;AGC 会以 path dependency 复用 `agent-runtime-core`、`platform-llm`、`platform-agent` 和 `shared-contracts`,但两边默认仍分别写入 `server-rs/target` 与 `apps/ai-game-creator-shell/src-tauri/target`。这个代码和锁文件边界继续保留,不为节省磁盘直接合并 workspace;生产构建脚本和 Tauri 发布还依赖当前 manifest / lock / target 身份。 @@ -334,6 +340,17 @@ npm run check ### 编译告警的保留边界与待优化项 +- 设计会话的水合、重置和调试快进通过共享核心验证损坏会话隔离、不确定批次恢复和事件失败后的落盘状态;仅 Tauri 包装限定生产编译,资源与模型客户端仍在原校验阶段之后按需加载。旧 UI 绑定、合并及语义建议命令已随现役 UI 工作流替换而删除;分离恢复测试验证已完成的绑定和图片不被重新生成,不代表首次生成全链路已覆盖。音频派发测试验证使用派发时的 revision、保留提交身份,并在远程上下文失败时保持 Prepared 账本及项目内容不变。 +- AGC 纯生产接入按模块限定 `cfg(not(test))`:桌面启动、Tauri 命令注册和没有测试消费者的命令接入集中到对应生产模块;共享业务实现、被测试直接调用的入口和现有断言仍参与测试编译。拆分不改变命令名、注册集合、权限校验或启动/退出顺序,也不新增 `allow(dead_code)`、虚假引用或仅为消警的测试。每批同时检查 AGC 普通目标和测试目标,源码结构检查跟随真实接线位置更新;不能用测试目标通过代替生产目标检查,也不以清零告警为由排除已有测试覆盖。 +- 插件/客户端扩展、模板库和预览的桌面命令与所属模块的共享实现分离:插件进程及权限逻辑、扩展仓库、模板访问授权/归档校验/建项、预览注册表与生命周期继续由原测试验证。模板命令和普通建项命令共用的 AppData 根目录选择接入随生产模块编译,用户所选目录的安全校验仍保留在共享实现中。 +- 编辑器适配器注册、随包资源定位和平台会话 fixture 加载属于桌面/Runner 启动接入,按生产模块编译;fixture 的发行版拒绝、路径边界、文件大小及凭据校验不得随拆分改变。配置 setup、Windows 提权 helper 的票据消费和 Runner bootId 初始化随其生产调用方限定范围,配置迁移、ACL 安全规则、平台身份/凭据轮换、编辑器执行回执及进程归属的已有测试继续编译。 +- 项目快照的周期调度、窗口事件、退出等待和桌面命令与共享同步核心分离;Analytics 的服务初始化、窗口/退出事件、上传定时启动和启动时补投同样属于生产接入。窗口登记状态机、快照索引与同步事务、埋点状态机、上传轮次和补投资格处理继续参与测试,不能用移除这些测试的方式消除告警。 +- Runner 服务监听循环、连接读写接入、心跳和 GUI owner 监控按生产边界编译;共享 RPC 分发、endpoint 校验、请求幂等、项目 owner 和生命周期测试继续参与测试。只读配置入口有 Unix 专属测试,必须保留 `any(not(test), unix)` 的使用范围,不能直接归入仅生产模块。 +- MCP 的 STDIO 启动和 Tauri 命令包装限定生产编译;loopback 启停、服务注册表、析构与工具桥启动属于 Claude Direct 的共享依赖,必须参与测试编译。认证链路调用的 `validate_platform_session_input`、线程队列使用的 `DirectGameCreatorTurnUpdateEmitter::new` 同样保留在测试编译中;Direct/设计工具及 UI 编辑器的纯命令包装随桌面入口编译。Thread Manager 的订阅/消费同时服务 Unix 集成测试与跨平台取消回合回归,不限定为生产或 Unix。项目资源的生产命令 DTO 与读取接入按实际消费者限定范围;分类更新测试从正式 camelCase 请求解码后验证同一身份与 revision 的写入,音频提交测试核对派发 operationId 与账本任务一致、幂等键不被替换。业务编排、网络鉴权和持久化安全逻辑不能仅因没有直接测试而整段排除。 +- Direct 取消入口必须验证错轮请求不取消、不释放、不发送终态;执行器退出后的残留守卫释放须返回原 clientTurnId,并发布对应 canonical userItemId 的 aborted 终态。旧守卫析构不能清掉随后启动的新回合。此类安全行为通过真实入口回归保留在测试构建,不以条件编译排除取消链。 +- 预览测试使用系统临时目录,不依赖 Windows 未必提供的 `HOME`。Cocos 插件 RPC 往返测试在注册完成后,通过只读状态查询确认插件已接收启动时的项目上下文,再执行原有隐式项目路径断言;Unity 跨项目测试先等待项目事件订阅,再切换项目。不能把注册完成等同于项目订阅快照已到达,也不能重试写操作掩盖初始化竞态。 +- AGC 测试编译使用 `cargo check --locked --offline --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -p genarrative-ai-game-creator-shell --tests` 定向核对。GUI 主入口在测试构建中被排除,不能把测试目标的 `dead_code` 一律认定为废弃生产代码;仅生产入口使用的导入限定 `not(test)`,平台测试导入与对应 Unix/Linux 测试保持相同条件,失去调用方的测试 helper 和多余重导出直接删除。DirectHome 命令配置测试明确断言禁用 `shell_tool` 与 `unified_exec`;Windows 路径校验的测试分支不触发宿主提权修复,修复范围参数仅由生产分支消费。 +- 测试 helper 的条件要覆盖真实使用范围:Unity delivery 观测 helper 限于 Windows x86_64 的 `unity-editor-execute` 测试,Agent DB 链接安全断言与 app-server 工作区启动包装限于 Unix 测试,模板假数据的环境变量读取限于 `template-library-fixtures` feature。底层生产实现与共享测试逻辑继续保留。仅检查完成阻塞协议固定中文措辞、且失去测试标记的遗留函数直接删除;现役自由执行请求使用短任务协议,不向其测试搬入标准模式的完成阻塞提示词要求,标准模式的生产协议保持不变。 - AGC 默认 Windows dev 构建在 2026-09-23 清理后的 `cargo check --locked --offline --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -p genarrative-ai-game-creator-shell` 复核通过,剩余 3 条 AuthBridge 相关 Rust warning 和 5 条 ts-rs 提示。这是当次配置的检查结果,不代表后续提交、正式 editor features、其它平台、test targets、release 链接或安装包均无告警;后续按改动范围定向验证。 - AuthBridge 的 3 条 warning 暂缓处理,支持或退役 OAuth 的决策仍未确定。完整证据、保留边界和关闭条件以 [AGC 主方案“OAuth 认证路线的契约冲突与待决边界”](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#oauth-认证路线的契约冲突与待决边界)为准。 - ts-rs 12.0.1 的 5 条提示来自四个枚举(`DirectCodexUserItem`、`DirectCodexUserContentPart`、`DirectThreadItem`、`DirectThreadEvent`)的 `serde(deny_unknown_fields)` 和图片组件的 `serde(deserialize_with = "deserialize_fill_amount")`。已静态核对 TypeScript 类型形状及基础类型正确、Serde 运行时校验仍有效;暂保留提示,不引入依赖补丁、不改业务校验或全局屏蔽,待上游正式版本支持后再评估。 @@ -644,6 +661,8 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null 后台“项目工程”(`/admin/#project-snapshots`)按项目列出远端快照,默认只看本部署渠道,顶部“渠道”选择框可切换远端已存在的其它渠道;列表按游标分页(每页 20/50/100,上一页复用已取得的游标,远端不给总数所以只显示当前页)。完整快照提供“下载完整工程”,按原始目录返回 ZIP;未完成同步的项目暂不可下载,旧清单缺少完整性声明时显示“完整性未知”,只能“下载已存文件”。“用户”列与“素材查询”同口径展示昵称与陶泥号,并可点开用户详情;不要直接把 OSS 的 `files/{size}-{digest}/` 目录下载当成工程。 +“按同步时间排序”按钮按需读取所选渠道全部项目,完成后按同步时间从新到旧、用户 ID 和项目 ID 字符串升序显示,并在浏览器本地按 20/50/100 条分页;列表会显示本轮项目总数。读取中可取消,失败保留原结果。排序模式下“刷新”重新读取全部项目,“恢复默认顺序”返回远端目录分页;切换渠道或重新登录清除临时排序结果。每次全量读取上限为 200 页、10,000 个唯一项目和 60 秒,超限报错,不将部分集合视作排序完成。该操作增加当次 OSS 清单读取量,不写 OSS 索引、不改数据库或 AGC 上传契约;并发同步结果以本轮读取集合为准,最新变化需刷新。 + 自动上传以原生登记的活动工程为准:打开即首传、每 300 秒周期同步、切换/关闭补传。排障同时核对 AppData `project-snapshots` 索引、`project_snapshot.sync.*` 日志和远端清单;只有测试项目的历史清单不能证明现役项目同步生效。前端在同一窗口内切项目时必须登记生命周期,不能只检查 URL 是否包含 `projectPath`。 后台枚举另外需要 AGC 私有前缀的 `ListObjects`(RAM `oss:ListObjects`,限制 prefix)与 `GetObject` 权限;下载不需要写入权限。客户端修复、后台页面与 api-server 必须分别发布才能在安装版和线上后台使用。本地定向测试及页面模拟不能代替发布后的真实上传与 ZIP 下载验收。 diff --git a/package.json b/package.json index cf5a355c7..c26f72011 100644 --- a/package.json +++ b/package.json @@ -184,6 +184,10 @@ "agc:build": "npm --prefix apps/ai-game-creator-shell run build --", "agc:skill-pack:check": "npm --prefix apps/ai-game-creator-shell run skill-pack:check", "agc:skill-pack:sync": "npm --prefix apps/ai-game-creator-shell run skill-pack:sync", + "agc:bundled-resources:check": "npm --prefix apps/ai-game-creator-shell run bundled-resources:check", + "agc:bundled-resources:sync": "npm --prefix apps/ai-game-creator-shell run bundled-resources:sync", + "agc:bundled-resources:prepare": "npm --prefix apps/ai-game-creator-shell run bundled-resources:prepare --", + "agc:bundled-resources:test": "npm --prefix apps/ai-game-creator-shell run bundled-resources:test --", "agc:plugins:test": "node --test plugins/agc-cocos-editor/src/entry.test.mjs plugins/agc-unity-editor/src/entry.test.mjs plugins/agc-godot-editor/src/entry.test.mjs", "agc:plugins:native-test": "cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml && cargo test --locked --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml && cargo test --locked --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml", "agc:plugins:check": "npm run agc:plugins:test && npm run agc:plugins:native-test", @@ -207,7 +211,7 @@ "agent-runtime-core:check": "cargo test --manifest-path server-rs/crates/agent-runtime-core/Cargo.toml", "agent-runtime-orchestration:check": "cargo test --manifest-path server-rs/crates/agent-runtime-orchestration/Cargo.toml", "ai-game-creator-shell:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck", - "ai-game-creator-shell:check:web": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests", + "ai-game-creator-shell:check:web": "npm run ai-game-creator-shell:typecheck && npm --prefix apps/ai-game-creator-shell run bundled-resources:test && npm run test -- apps/ai-game-creator-shell/tests", "ai-game-creator-shell:check:rust:crates": "npm run agent-runtime-core:check && npm run agent-runtime-orchestration:check && cargo test --locked -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app && npm run check:generated-bindings", "ai-game-creator-shell:check:rust:shell": "node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4", "ai-game-creator-shell:check:rust": "npm run ai-game-creator-shell:check:rust:crates && npm run ai-game-creator-shell:check:rust:shell", diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 1c08b8741..541e0c06e 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -31,10 +31,13 @@ const aiGameCreatorProjectDevelopmentSource = fs.readFileSync( 'apps/ai-game-creator-shell/src/view/project-development/index.tsx', 'utf8', ); -const aiGameCreatorPreviewRustSource = fs.readFileSync( +// 同时检查共享预览实现和桌面命令接入,避免拆分后漏掉激活或外部浏览器边界。 +const aiGameCreatorPreviewRustSource = [ 'apps/ai-game-creator-shell/src-tauri/src/preview.rs', - 'utf8', -); + 'apps/ai-game-creator-shell/src-tauri/src/preview/desktop.rs', +] + .map((sourcePath) => fs.readFileSync(sourcePath, 'utf8')) + .join('\n'); const aiGameCreatorShellTauriConfig = JSON.parse( fs.readFileSync( 'apps/ai-game-creator-shell/src-tauri/tauri.conf.json', diff --git a/scripts/project-ci-workflow.test.ts b/scripts/project-ci-workflow.test.ts index 283f6a53a..16bc6b87b 100644 --- a/scripts/project-ci-workflow.test.ts +++ b/scripts/project-ci-workflow.test.ts @@ -707,7 +707,7 @@ describe('project CI workflow', () => { 'npm run ai-game-creator-shell:check:web && npm run ai-game-creator-shell:check:rust && npm run ai-game-creator-shell:agent-run:smoke', ); expect(rootPackageJson.scripts?.['ai-game-creator-shell:check:web']).toBe( - 'npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests', + 'npm run ai-game-creator-shell:typecheck && npm --prefix apps/ai-game-creator-shell run bundled-resources:test && npm run test -- apps/ai-game-creator-shell/tests', ); expect(rootPackageJson.scripts?.['ai-game-creator-shell:check:rust']).toBe( 'npm run ai-game-creator-shell:check:rust:crates && npm run ai-game-creator-shell:check:rust:shell', diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index 52f02feca..39b91746a 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -2879,6 +2879,52 @@ mod tests { ); } + #[test] + fn wallet_ledger_list_drops_zero_change_entries_without_reordering_the_rest() { + let mut entries = vec![ + wallet_ledger_snapshot("daily-free-reset", 0, 97, 4), + wallet_ledger_snapshot("recharge-60", 60, 97, 3), + wallet_ledger_snapshot("membership-period-reset", 0, 37, 2), + wallet_ledger_snapshot("recharge-180-delayed", 180, 37, 1), + ]; + + retain_profile_wallet_ledger_entries_with_change(&mut entries); + + assert_eq!( + entries + .iter() + .map(|entry| entry.wallet_ledger_id.as_str()) + .collect::>(), + vec!["recharge-60", "recharge-180-delayed"] + ); + } + + #[test] + fn expiring_points_transition_reports_zero_change_when_balance_is_unchanged() { + assert_eq!( + resolve_expiring_points_wallet_transition(0, 0, 0), + (0, 0), + "没有额度和发放时不产生账单" + ); + assert_eq!( + resolve_expiring_points_wallet_transition(5, 5, 5), + (5, 0), + "每日免费 / 会员周期到期额度等于发放额度时余额不变" + ); + assert_eq!( + resolve_expiring_points_wallet_transition(0, 5, 0), + (0, 0), + "余额不足以扣回到期额度时不产生负向账单" + ); + } + + #[test] + fn expiring_points_transition_reports_real_balance_change() { + assert_eq!(resolve_expiring_points_wallet_transition(0, 0, 5), (5, 5)); + assert_eq!(resolve_expiring_points_wallet_transition(5, 5, 0), (0, -5)); + assert_eq!(resolve_expiring_points_wallet_transition(3, 5, 2), (2, -1)); + } + #[test] fn historical_wallet_spend_counts_asset_and_llm_consumption() { assert_eq!( @@ -4453,11 +4499,20 @@ fn list_profile_wallet_ledger_entries( let current_balance = profile_wallet_balance(ctx, &validated_input.user_id); sort_profile_wallet_ledger_entries(&mut entries, current_balance); + retain_profile_wallet_ledger_entries_with_change(&mut entries); entries.truncate(PROFILE_WALLET_LEDGER_LIST_LIMIT); Ok(entries) } +// 账单只呈现真实余额变化:历史遗留的 0 变动流水在这里统一剔除,排序链已经按余额反推完成, +// 去掉 0 变动行不改变其余条目的先后与 `balance_after` 语义,也不占用列表上限。 +fn retain_profile_wallet_ledger_entries_with_change( + entries: &mut Vec, +) { + entries.retain(|entry| entry.amount_delta != 0); +} + fn sort_profile_wallet_ledger_entries( entries: &mut Vec, current_balance: u64, @@ -8843,6 +8898,18 @@ fn membership_cycle_metadata( .unwrap_or_else(|_| PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()) } +// 每日免费 / 会员周期额度换期的余额迁移:返回换期后的余额与实际变动。 +// 到期额度和发放额度相同时余额不变,调用方不得再落一条 0 变动账单。 +fn resolve_expiring_points_wallet_transition( + previous_balance: u64, + expired_points: u64, + granted_points: u64, +) -> (u64, i64) { + let after_expiry = previous_balance.saturating_sub(expired_points); + let next_balance = after_expiry.saturating_add(granted_points); + (next_balance, next_balance as i64 - previous_balance as i64) +} + fn update_profile_wallet_balance_for_expiring_points( ctx: &ReducerContext, user_id: &str, @@ -8853,9 +8920,6 @@ fn update_profile_wallet_balance_for_expiring_points( created_at: Timestamp, metadata_json: String, ) { - if expired_points == 0 && granted_points == 0 { - return; - } if ctx .db .profile_wallet_ledger() @@ -8872,8 +8936,11 @@ fn update_profile_wallet_balance_for_expiring_points( .user_id() .find(&user_id.to_string()); let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0); - let after_expiry = previous_balance.saturating_sub(expired_points); - let next_balance = after_expiry.saturating_add(granted_points); + let (next_balance, amount_delta) = + resolve_expiring_points_wallet_transition(previous_balance, expired_points, granted_points); + if amount_delta == 0 { + return; + } let created_state_at = current .as_ref() .map(|row| row.created_at) @@ -8908,7 +8975,7 @@ fn update_profile_wallet_balance_for_expiring_points( ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger { wallet_ledger_id: ledger_id.to_string(), user_id: user_id.to_string(), - amount_delta: next_balance as i64 - previous_balance as i64, + amount_delta, balance_after: next_balance, source_type, created_at, @@ -10422,6 +10489,10 @@ fn apply_profile_wallet_signed_delta( let settled_at = ctx.timestamp; let ledger_recorded_at = profile_wallet_ledger_recorded_at(created_at, settled_at); refresh_profile_wallet_expiring_points(ctx, user_id, settled_at); + // 实际变动为 0 的结算不产生账单流水:既不改变余额,也不该在账单里占一条 0。 + if amount_delta == 0 { + return Ok(profile_wallet_balance(ctx, user_id)); + } if idempotent { if let Some(existing) = ctx .db