diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 8338196cf..a5694037f 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -47,8 +47,8 @@ env: # 各自顺序运行两片,让一次 job 只做一次依赖预热,同时避免同一容器内多进程并行争抢 # 共享 HOME、target 与固定临时路径。每个分片调用仍会自校验「片并集等于全集且互斥」。 jobs: - # AGC 壳自身的 Rust bin 单测由两条 lane 承载,每条 lane 顺序运行两片;这里不装 npm - # 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块,也只需要 AGC 壳自己那份锁定依赖。 + # AGC 壳自身的 Rust bin 单测由两条 lane 承载,每条 lane 顺序运行两片; + # build.rs 会准备 Claude Agent SDK sidecar,因此编译前必须安装根 workspace npm 依赖。 ai-game-creator-shell-rust-lane-1: name: AI game creator shell Rust lane 1/2 runs-on: genarrative-ci @@ -62,6 +62,9 @@ jobs: - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + - name: Prepare AI game creator shell Rust dependencies shell: bash run: | @@ -118,6 +121,9 @@ jobs: - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + - name: Prepare AI game creator shell Rust dependencies shell: bash run: | @@ -154,7 +160,7 @@ jobs: GENARRATIVE_GITEA_TOKEN: ${{ github.token }} run: python3 scripts/export-gitea-rust-cache.py - # agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖, + # agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样需要 npm 随包依赖, # 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。 ai-game-creator-shell-rust-smoke: name: AI game creator shell Rust smoke @@ -169,6 +175,9 @@ jobs: - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + - name: Prepare AI game creator shell Rust dependencies shell: bash run: | 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/CONTEXT.md b/CONTEXT.md index c22344ea0..00f7c178e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -192,7 +192,7 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI ## 项目开发对话(DirectProject) **DirectProject 专属聊天模块**: -AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。 +AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、待发消息队列的投影、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。 _Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源 **项目工作台布局**: @@ -208,20 +208,32 @@ Thread Manager 向订阅者推送的当前回合原始事件流,只服务运 _Avoid_: 进度通知、快照轮询、第二套历史 **逻辑回合**: -Thread Manager 拥有的一对回合边界(开始与结束),由接单动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。 +Thread Manager 拥有的一对回合边界(开始与结束),由放行动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。 _Avoid_: Codex 原生回合、原生日志、进程生命周期 -**接单**: -把一条用户消息交给宿主开始执行的动作,成立即表示这一轮已经存在;此后结果只由运行态事件回答。 -_Avoid_: 发送成功、命令调用、接口返回 +**待发消息队列**: +Thread Manager 按项目持有的待发用户消息序列,只支持按入队顺序追加与按身份移除,状态由运行态事件派生,不落盘、不构成第二份事实源。 +_Avoid_: 前端本地队列、队列副本、待发消息的持久化记录 -**拒单**: -接单成立之前拒绝这次请求(并发、权限、目录、参数、工程准备未就绪),只回一条可展示原因,不产生回合事件,也不写用户条目。 +**待发消息**: +已经通过入队检查、等待被放行的用户消息;它在放行之前不是回合,不写用户条目、不产生回合事件。 +_Avoid_: 回合、在途回合、草稿 + +**入队**: +把一条用户消息交给宿主的动作:宿主跑完入队检查后把它放进待发消息队列;入队成立只表示这条消息会按顺序被放行。 +_Avoid_: 发送成功、已经开跑、回合成立 + +**入队失败**: +入队检查未通过(身份、形状、容量、权限、目录、参数、工程准备未就绪)时拒绝这次请求,只回一条可展示原因,不入队、不产生回合事件,也不写用户条目。 _Avoid_: 回合失败、执行失败、失败事件 +**放行**: +Thread Manager 在一个回合收口之后把队首的待发消息送进回合:同一临界区里登记占用、落盘用户条目、发出逻辑回合开始事件并起整轮;放行之后的结果只由运行态事件回答。 +_Avoid_: 前端放行、定时轮询、放行失败 + **在途回合**: -界面本地已经把这条用户消息发出去、宿主还没有对应回合开始事件的那一小段状态。 -_Avoid_: 运行中回合、乐观锁、发送队列 +界面本地已经入队、宿主还没有对应回合开始事件的那一小段状态。 +_Avoid_: 运行中回合、乐观锁、前端发送队列 **聊天投影**: 把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。 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/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..ca6897814 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1358,7 +1358,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 +1386,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 +1399,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 +1445,7 @@ assert.deepEqual( ]), ), ['resources/plugins', 'plugins'], + ['resources/claude-agent', 'claude-agent'], ]), 'macOS must bundle the complete native Codex layout and plugin workspace', ); diff --git a/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs index 5d4fa9881..5fd1f2aa2 100644 --- a/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs +++ b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs @@ -5,6 +5,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { listForbiddenBundledResourceFiles } from './macos-bundle-policy.mjs'; + // 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。 assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行'); const source = path.resolve(process.argv[2] || ''); @@ -287,22 +289,32 @@ try { ]) { assert.ok(fs.existsSync(path.join(plugin, file)), file); } - // 随包 Node 的 npm 是包里唯一允许出现的 node_modules:除了 npm 目录自身与它的子项, - // 还要放行它的上级目录 `game-runtime/node/node_modules`(recursive readdir 会列出目录项, - // 少了这一条会让整个门禁对合法包失败——#439 引入后一直没被跑到,直到 2026-09-21 才暴露)。 - const allowedNodeModules = (file) => - file === 'game-runtime/node/node_modules' || - file === 'game-runtime/node/node_modules/npm' || - file.startsWith('game-runtime/node/node_modules/npm/'); + const claudeAgentRoot = path.join(resources, 'claude-agent'); + const claudeAgentSdk = path.join( + claudeAgentRoot, + 'node_modules/@anthropic-ai/claude-agent-sdk', + ); + const claudeAgentBinaryPackage = `claude-agent-sdk-darwin-${architecture === 'arm64' ? 'arm64' : 'x64'}`; + const claudeAgentBinary = path.join( + claudeAgentRoot, + 'node_modules/@anthropic-ai', + claudeAgentBinaryPackage, + 'claude', + ); + for (const required of [ + path.join(claudeAgentRoot, 'index.mjs'), + path.join(claudeAgentSdk, 'sdk.mjs'), + claudeAgentBinary, + ]) { + assert.ok(fs.existsSync(required), required); + } + // 随包只允许 Node runtime 的 npm 与 Claude Agent SDK sidecar 这两棵 + // node_modules 子树;任何其它 node_modules 都是构建残留或不可控依赖。 const packageFiles = fs.readdirSync(resources, { recursive: true }); - assert.ok( - !packageFiles.some( - (file) => - /(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/.test( - file, - ) || - (/(^|\/)node_modules(\/|$)/.test(file) && !allowedNodeModules(file)), - ), + assert.deepEqual( + listForbiddenBundledResourceFiles(packageFiles), + [], + '安装包包含禁止资源', ); assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version); assert.equal( 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/direct-execution-production-fixture.mjs b/apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs deleted file mode 100644 index cbf428c81..000000000 --- a/apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs +++ /dev/null @@ -1,1171 +0,0 @@ -// Real AGC CLI -> bundled app-server -> loopback Responses/MCP fixtures. -// No account credentials, installed AppData, or paid Provider are used. -import assert from 'node:assert/strict'; -import { spawn, spawnSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import fs from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const repo = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '../../..', -); -const args = process.argv.slice(2); -const option = (name) => { - const i = args.indexOf(name); - return i < 0 ? undefined : args[i + 1]; -}; -const executable = option('--agc-exe'); -assert( - executable && path.isAbsolute(executable), - 'pass --agc-exe with the newly built debug AGC binary', -); -const codex = path.resolve( - option('--codex-exe') ?? - path.join( - repo, - 'node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc/bin/codex.exe', - ), -); -const cases = ( - option('--cases') ?? 'completed,passes,mcp,mcp-write,native,deadline' -).split(','); -const root = await fs.mkdtemp( - path.join(os.tmpdir(), 'agc-execution-production-'), -); -console.log(JSON.stringify({ evidenceRoot: root })); -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const hash = (value) => createHash('sha256').update(value).digest('hex'); -const exists = async (file) => - fs.stat(file).then( - () => true, - () => false, - ); -const quote = (text) => - process.platform === 'win32' - ? "'" + text.replaceAll("'", "''") + "'" - : "'" + text.replaceAll("'", "'\\''") + "'"; -const nodeCommand = (source) => - (process.platform === 'win32' ? '& ' : '') + - quote(process.execPath) + - ' -e ' + - quote( - process.platform === 'win32' - ? // Windows PowerShell 5 removes nested double quotes from native argv. - "eval(Buffer.from('" + - Buffer.from(source).toString('base64') + - "','base64').toString())" - : source, - ); -const call = (id, name, arguments_, namespace) => ({ - type: 'function_call', - id: 'item-' + id, - call_id: id, - name, - ...(namespace ? { namespace } : {}), - arguments: JSON.stringify(arguments_), -}); -// Codex 0.155 起原生执行入口是统一 exec:`exec_command` + `write_stdin`(旧 `shell_command` -// 已不再注册)。命令在 yield_time_ms 内结束时不返回 session,保持与旧用例同样的同步语义。 -const native = (id, source) => - call(id, 'exec_command', { - cmd: nodeCommand(source), - yield_time_ms: 30_000, - }); -const register = (artifact) => - call( - 'contract', - 'agc_register_delivery_contract', - { - scope: - '仅测试客户端执行许可,修改临时已有工程的标记文件,不生成素材或运行游戏。', - changeKind: 'project', - requirements: [{ kind: 'artifact', id: 'marker', path: artifact }], - }, - 'mcp__agc_tools', - ); -const final = { - type: 'message', - id: 'fixture-final', - role: 'assistant', - content: [ - { - type: 'output_text', - text: 'Fixture actions finished; use the host delivery result.', - }, - ], -}; -// 工具回执正文既可能是字符串,也可能是 input_text 分片数组。 -const toolOutputText = (value) => - typeof value === 'string' - ? value - : Array.isArray(value) - ? value.map((part) => part?.text ?? '').join('') - : ''; -// 原生工具的输入 schema 只随第一份请求回执留档一次,用于版本升级后核对参数形状。 -const NATIVE_SCHEMA_TOOLS = new Set([ - 'exec_command', - 'write_stdin', - 'shell_command', - 'view_image', - 'list_mcp_resources', - 'list_mcp_resource_templates', - 'read_mcp_resource', -]); -const describeTool = (tool, withSchema = false) => ({ - type: tool.type, - name: tool.name ?? tool.function?.name, - ...(tool.namespace ? { namespace: tool.namespace } : {}), - ...(withSchema && tool.parameters ? { parameters: tool.parameters } : {}), - ...(Array.isArray(tool.tools) - ? { tools: tool.tools.map((entry) => describeTool(entry, false)) } - : {}), -}); - -async function walk(directory) { - const output = []; - for (const item of await fs - .readdir(directory, { withFileTypes: true }) - .catch(() => [])) { - const file = path.join(directory, item.name); - if (item.isDirectory()) output.push(...(await walk(file))); - else if (item.isFile()) output.push(file); - } - return output; -} - -async function readLedger(host) { - for (const file of await walk(path.join(host, 'direct-executions'))) { - if (!file.endsWith('.json')) continue; - const value = await fs - .readFile(file, 'utf8') - .then(JSON.parse) - .catch(() => null); - if (value?.schemaVersion === 'agc-direct-execution.v1') - return { file, value }; - } - return null; -} - -/** - * `restart` 用例判据:回合进行中杀掉进程后 - * - 已落盘的用户消息、部分助手文本与工具卡片必须按原顺序保留; - * - 宿主不得为这一轮伪造终态(不能出现最终助手消息,账本不能是 completed / executorStopped)。 - * 对应的里程碑条款是「回合进行中终止并重启进程后重进项目:已落盘的部分文本与工具卡片按原顺序出现, - * 且界面不显示忙碌态」里**宿主侧可证的那一半**(界面渲染仍要真实客户端窗口)。 - */ -async function assertRestartKeepsPartialHistory(project, ledger, exit) { - assert( - exit.code !== 0 || exit.signal, - 'restart: AGC must be killed mid-turn, got ' + JSON.stringify(exit), - ); - const history = await fs - .readFile(path.join(project, '.agent/conversations/project.jsonl'), 'utf8') - .catch(() => ''); - const rows = history.split('\n').filter(Boolean); - const raw = rows.join('\n'); - // 注意:CLI / 无前端宿主不写用户消息(GUI 是前端先调 append_direct_project_conversation_message), - // 所以这里只断言"部分文本 + 工具卡片按原顺序落盘",用户消息的持久化属于 GUI 路径。 - const order = ['RESTART-PARTIAL-TEXT', 'exec_command'].map((needle) => - raw.indexOf(needle), - ); - assert( - order.every((index) => index >= 0) && order[0] < order[1], - 'restart: partial text and tool card must stay in order; got ' + - JSON.stringify({ order, rows: rows.length }), - ); - assert( - !raw.includes('Fixture actions finished'), - 'restart: killed turn must not fabricate the final assistant message', - ); - // 被杀的工具卡片必须是最后一条:既证明卡片已落盘,也证明它没有产生任何回执 - // (有回执就说明这一轮跑到了工具结束之后,不再是"进行中终止")。 - const lastRow = rows[rows.length - 1] ?? ''; - assert( - lastRow.includes('restart-slow') && lastRow.includes('exec_command'), - 'restart: the killed tool card must be the last persisted row; got ' + - lastRow.slice(0, 200), - ); - assert( - !rows.some( - (row) => row.includes('restart-slow') && row.includes('"output"'), - ), - 'restart: the killed tool must not have produced an output row', - ); - assert.notEqual( - ledger.phase, - 'completed', - 'restart: killed turn must not claim completion', - ); - assert.notEqual( - ledger.executorStopped, - true, - 'restart: killed turn must not report a fully stopped executor', - ); -} - -function ownFixtureTree(directory) { - if (process.platform !== 'win32') return; - assert(path.resolve(directory).startsWith(path.resolve(root) + path.sep)); - // Elevated Windows shells otherwise create Administrators-owned objects. - // Only this newly created fixture subtree is adjusted to its launching user. - const result = spawnSync( - 'powershell.exe', - [ - '-NoProfile', - '-NonInteractive', - '-Command', - "$ErrorActionPreference='Stop'; $fixtureRoot=$env:AGC_FIXTURE_OWNER_ROOT; " + - '$fixtureSid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User; ' + - '$fixtureItems=@(Get-Item -LiteralPath $fixtureRoot)+@(Get-ChildItem -LiteralPath $fixtureRoot -Recurse -Force); ' + - 'foreach($fixtureItem in $fixtureItems){$fixtureAcl=$fixtureItem.GetAccessControl(); $fixtureAcl.SetOwner($fixtureSid); $fixtureItem.SetAccessControl($fixtureAcl)}', - ], - { - env: { ...process.env, AGC_FIXTURE_OWNER_ROOT: directory }, - windowsHide: true, - encoding: 'utf8', - }, - ); - assert.equal( - result.status, - 0, - 'fixture ownership setup failed: ' + result.stderr, - ); -} - -async function installFixtureMcp( - host, - directory, - readOnlyHint, - delayMs = 800, - doneMarker = '', -) { - const source = path.join(host, 'extensions', 'sources', 'fixture'); - await fs.mkdir(source, { recursive: true }); - const program = path.join(source, 'server.cjs'); - const dispatch = path.join(directory, 'mcp-dispatch.jsonl'); - await fs.writeFile( - program, - [ - "const rl=require('node:readline').createInterface({input:process.stdin});", - "const fs=require('node:fs'); const target=process.argv[2];", - "rl.on('line',async(line)=>{let x;try{x=JSON.parse(line)}catch{return}if(x.id===undefined)return;", - 'let result;switch(x.method){', - "case 'initialize':result={protocolVersion:x.params.protocolVersion,capabilities:{tools:{},resources:{}},serverInfo:{name:'fixture',version:'1'}};break;", - "case 'tools/list':result={tools:[{name:'execute',description:'Fixture execution with explicit MCP annotations',inputSchema:{type:'object',properties:{step:{type:'integer'}},required:['step']},annotations:{readOnlyHint:" + - readOnlyHint + - ',idempotentHint:true}}]};break;', - "case 'tools/call':fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'start',method:x.method,arguments:x.params.arguments,at:Date.now()})+'\\n');await new Promise(r=>setTimeout(r," + - delayMs + - "));if(process.argv[3])fs.writeFileSync(process.argv[3],'done');fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'end',at:Date.now()})+'\\n');result={content:[{type:'text',text:'fixture dispatched'}]};break;", - "case 'resources/list':case 'resources/templates/list':case 'resources/read':fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'start',method:x.method,at:Date.now()})+'\\n');await new Promise(r=>setTimeout(r,800));fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'end',at:Date.now()})+'\\n');result=x.method==='resources/list'?{resources:[{uri:'fixture://state',name:'state',mimeType:'text/plain'}]}:x.method==='resources/templates/list'?{resourceTemplates:[{uriTemplate:'fixture://{name}',name:'fixture',mimeType:'text/plain'}]}:{contents:[{uri:x.params.uri,text:'fixture resource',mimeType:'text/plain'}]};break;", - "case 'ping':result={};break;", - "default:process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:x.id,error:{code:-32601,message:'unsupported fixture method'}})+'\\n');return;", - "}process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:x.id,result})+'\\n');});", - ].join('\n'), - ); - const config = { - command: process.execPath, - args: [program, dispatch, doneMarker], - tool_timeout_sec: 10, - }; - const document = JSON.stringify({ mcpServers: { fixture: config } }); - await fs.writeFile(path.join(source, 'mcp.json'), document); - await fs.writeFile( - path.join(host, 'extensions', 'index.json'), - JSON.stringify({ - schemaVersion: 'direct-project-client-extensions.v1', - sources: [ - { - id: 'fixture-source', - originalName: 'fixture', - storagePath: 'sources/fixture', - fingerprint: hash(document), - }, - ], - items: [ - { - id: 'fixture-mcp', - sourceId: 'fixture-source', - extensionType: 'mcp', - name: 'fixture', - originalName: 'fixture', - sourceRelativePath: 'mcp.json', - enabled: true, - fingerprint: hash(document), - lastError: null, - mcpConfig: config, - }, - ], - }), - ); - return dispatch; -} - -async function runScenario(name) { - assert( - [ - 'completed', - 'passes', - 'mcp', - 'mcp-write', - 'native', - 'native-resources', - 'patch', - 'deadline', - 'native-session', - 'restart', - 'running', - ].includes(name), - 'unknown fixture case', - ); - const directory = path.join(root, name); - const project = path.join(directory, 'project'); - const host = path.join(directory, 'host'); - const home = path.join(directory, 'home'); - await Promise.all( - [ - project, - host, - home, - path.join(home, 'appdata'), - path.join(home, 'local'), - ].map((dir) => fs.mkdir(dir, { recursive: true })), - ); - // An existing editor project avoids turning this execution-boundary fixture - // into a new-Web-game/bootstrap/visual-quality test. - await fs.writeFile( - path.join(project, 'project.godot'), - 'config_version=5\n[application]\nconfig/name="AGC execution fixture"\n', - ); - const isMcp = name === 'mcp' || name === 'mcp-write'; - const mcpDispatch = - isMcp || name === 'native-resources' || name === 'patch' - ? await installFixtureMcp( - host, - directory, - name === 'mcp', - name === 'patch' ? 4_000 : 800, - name === 'patch' ? path.join(project, 'mcp-done.txt') : '', - ) - : null; - const writes = (filename, text) => - "require('node:fs').writeFileSync(" + - JSON.stringify(filename) + - ',' + - JSON.stringify(text) + - ');'; - const nativeOverlapCall = (id) => - native( - id, - "const f=require('node:fs');const file=" + - JSON.stringify(id + '.jsonl') + - ";const mark=phase=>f.appendFileSync(file,JSON.stringify({phase,at:Date.now()})+'\\n');mark('start');setTimeout(()=>mark('end'),1000);", - ); - const plan = - name === 'completed' - ? [ - [register('marker.txt')], - [native('marker', writes('marker.txt', 'verified'))], - [ - native( - 'forbidden-after-completion', - writes('forbidden.txt', 'must not run'), - ), - ], - ] - : name === 'deadline' - ? [ - [register('never.txt')], - [ - native( - 'slow', - writes('started.txt', 'started') + - 'setTimeout(()=>{' + - writes('late.txt', 'must not run') + - '},8000);', - ), - ], - ] - : isMcp - ? [ - [register('never.txt')], - [ - call('mcp-a', 'execute', { step: 1 }, 'mcp__fixture'), - call('mcp-b', 'execute', { step: 1 }, 'mcp__fixture'), - ], - [native('fail', 'process.exit(1);')], - [call('forbidden-mcp', 'execute', { step: 2 }, 'mcp__fixture')], - ] - : name === 'native' - ? [ - [register('never.txt')], - [nativeOverlapCall('native-a'), nativeOverlapCall('native-b')], - [native('fail', 'process.exit(1);')], - [ - native( - 'forbidden-native', - writes('forbidden.txt', 'must not run'), - ), - ], - ] - : name === 'native-resources' - ? [ - [register('never.txt')], - [ - 'list_mcp_resources', - 'list_mcp_resource_templates', - 'read_mcp_resource', - ].flatMap((tool) => - [1, 2].map((i) => - call(tool + '-' + i, tool, { - server: 'fixture', - ...(tool === 'read_mcp_resource' - ? { uri: 'fixture://state' } - : {}), - }), - ), - ), - [native('fail', 'process.exit(1);')], - ] - : name === 'native-session' - ? [ - [register('session.txt')], - [ - call('session', 'exec_command', { - cmd: nodeCommand( - "const f=require('node:fs');setTimeout(()=>f.writeFileSync('session.txt','session completed'),12000);", - ), - yield_time_ms: 10_000, - }), - ], - (body) => { - // 统一 exec 超过 yield_time_ms 会返回会话号;用 write_stdin 轮询到会话结束。 - const output = (body.input ?? []) - .filter((item) => item.call_id === 'session') - .map((item) => toolOutputText(item.output)) - .join('\n'); - const match = /session ID (\d+)/.exec(output); - assert( - match, - 'exec_command did not return a unified exec session id: ' + - output.slice(0, 400), - ); - return [ - call('poll', 'write_stdin', { - session_id: Number(match[1]), - chars: '', - yield_time_ms: 20_000, - }), - ]; - }, - [final], - ] - : name === 'patch' - ? [ - [ - call( - 'contract', - 'agc_register_delivery_contract', - { - scope: - '验收并发补丁、计划与慢工具的正常完成;两个必需产物都必须出现。', - changeKind: 'project', - requirements: [ - { - kind: 'artifact', - id: 'patch', - path: 'patch-proof.txt', - }, - { - kind: 'artifact', - id: 'mcp', - path: 'mcp-done.txt', - }, - ], - }, - 'mcp__agc_tools', - ), - ], - [ - call( - 'slow-mcp', - 'execute', - { step: 1 }, - 'mcp__fixture', - ), - call( - 'patch', - 'agc_apply_patch', - { - patch: - '*** Begin Patch\n*** Environment ID: local\n*** Add File: patch-proof.txt\n+parallel patch proof\n*** End Patch', - }, - 'mcp__agc_tools', - ), - call( - 'plan', - 'agc_update_plan', - { - explanation: '并行计划回执', - plan: [ - { - step: 'parallel-plan-proof', - status: 'completed', - }, - ], - }, - 'mcp__agc_tools', - ), - ], - [ - native( - 'forbidden-patch-expansion', - writes('forbidden.txt', 'must not run'), - ), - ], - ] - : name === 'restart' - ? [ - [register('started.txt')], - [ - { - type: 'message', - id: 'fixture-restart-partial', - role: 'assistant', - content: [ - { - type: 'output_text', - text: 'RESTART-PARTIAL-TEXT', - }, - ], - }, - native( - 'restart-slow', - writes('started.txt', 'started') + - 'setTimeout(()=>{' + - writes('late.txt', 'must not run after kill') + - '},15000);', - ), - ], - ] - : name === 'running' - ? [ - [register('started.txt')], - [ - { - type: 'message', - id: 'fixture-running-partial', - role: 'assistant', - content: [ - { - type: 'output_text', - text: 'RUNNING-PARTIAL-TEXT', - }, - ], - }, - native( - 'running-slow', - writes('started.txt', 'started') + - 'setTimeout(()=>{},6000);', - ), - ], - [final], - ] - : [ - [register('never.txt')], - [native('build', 'console.log(1+1);')], - [native('test', 'console.log(2+2);')], - [native('fail', 'process.exit(1);')], - [ - native( - 'forbidden-after-budget', - writes('forbidden.txt', 'must not run'), - ), - ], - ]; - const requests = []; - const responses = []; - const httpArrivals = []; - const fixtureErrors = []; - const server = http.createServer(async (request, response) => { - httpArrivals.push({ - at: Date.now(), - method: request.method, - url: request.url, - expectedAuthorization: - request.headers.authorization === 'Bearer agc-loopback-fixture', - }); - try { - assert.equal(request.method, 'POST'); - assert(request.url.endsWith('/responses')); - assert.equal( - request.headers.authorization, - 'Bearer agc-loopback-fixture', - ); - const chunks = []; - let bytes = 0; - for await (const chunk of request) { - bytes += chunk.length; - assert(bytes <= 32 * 1024 * 1024, 'oversized fixture request'); - chunks.push(chunk); - } - const requestBody = JSON.parse(Buffer.concat(chunks).toString('utf8')); - const index = requests.length; - requests.push({ - index, - at: Date.now(), - model: requestBody.model, - parallelToolCalls: requestBody.parallel_tool_calls, - ...(index === 0 - ? { - toolCatalogue: (requestBody.tools ?? []).map((tool) => - describeTool(tool, NATIVE_SCHEMA_TOOLS.has(tool.name)), - ), - } - : {}), - // 只留档工具回执的有界原文,用于核对统一 exec 的会话/退出结果契约。 - inputs: (requestBody.input ?? []).map((item) => ({ - type: item.type, - name: item.name, - call_id: item.call_id, - raw: JSON.stringify(item).slice(0, 1500), - })), - }); - assert(index < 20, 'fixture model loop exceeded bound'); - if (index === plan.length - 1 && name !== 'deadline') { - // Allow host receipt settlement/sealing to win before an intentionally - // unwanted expansion. A closed transport is a valid pre-dispatch stop. - await sleep(300); - } - const step = plan[index] ?? [final]; - const output = typeof step === 'function' ? step(requestBody) : step; - - responses.push({ - index, - items: output.map((item) => JSON.stringify(item).slice(0, 300)), - }); - const body = { - id: 'response-' + index, - object: 'response', - model: 'gpt-5.1-codex', - status: 'completed', - output, - usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, - }; - const events = [ - { - type: 'response.created', - response: { ...body, status: 'in_progress', output: [] }, - }, - ]; - output.forEach((item, output_index) => { - events.push({ type: 'response.output_item.added', output_index, item }); - events.push({ type: 'response.output_item.done', output_index, item }); - }); - events.push({ type: 'response.completed', response: body }); - response.writeHead(200, { 'content-type': 'text/event-stream' }); - if (name === 'patch' && index === 1) { - // One response, streamed incrementally: prove the long operation really - // started before offering independent patch/plan calls to the scheduler. - response.write( - events - .slice(0, 3) - .map((event) => 'data: ' + JSON.stringify(event) + '\n\n') - .join(''), - ); - const startedDeadline = Date.now() + 5_000; - while (Date.now() < startedDeadline) { - const log = await fs.readFile(mcpDispatch, 'utf8').catch(() => ''); - if ( - log - .split('\n') - .some( - (line) => - line.includes('"method":"tools/call"') && - line.includes('"phase":"start"'), - ) - ) - break; - await sleep(20); - } - response.end( - events - .slice(3) - .map((event) => 'data: ' + JSON.stringify(event) + '\n\n') - .join(''), - ); - return; - } - response.end( - events - .map((event) => 'data: ' + JSON.stringify(event) + '\n\n') - .join(''), - ); - } catch (error) { - fixtureErrors.push(String(error)); - if (!response.destroyed) { - response.writeHead(400); - response.end(String(error)); - } - } - }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const address = server.address(); - await fs.writeFile( - path.join(host, 'game-creator.config.json'), - JSON.stringify({ - schemaVersion: 'game-creator-config.v2', - agentMode: 'codex_app_server', - llm: { - customEnabled: true, - apiKey: 'agc-loopback-fixture', - baseUrl: 'http://127.0.0.1:' + address.port + '/v1', - model: 'gpt-5.1-codex', - visibleModels: ['gpt-5.1-codex'], - apiKind: 'openai_responses', - stream: true, - webSearchEnabled: false, - reasoningEffort: 'low', - maxRetries: 0, - requestTimeoutMs: 60_000, - }, - validation: { - maxRuns: 1, - maxExecutionSeconds: name === 'deadline' ? 2 : 30, - maxTurnSeconds: 60, - }, - }), - ); - ownFixtureTree(directory); - const env = Object.fromEntries( - Object.entries(process.env).filter( - ([key]) => - !/TOKEN|SECRET|PASSWORD|API_KEY|AUTHORIZATION|COOKIE/i.test(key), - ), - ); - Object.assign(env, { - GENARRATIVE_AGC_DEBUG_PROVIDER_E2E: '1', - HOME: home, - USERPROFILE: home, - APPDATA: path.join(home, 'appdata'), - LOCALAPPDATA: path.join(home, 'local'), - PATH: path.dirname(codex) + path.delimiter + (env.PATH ?? env.Path ?? ''), - }); - let stdout = '', - stderr = ''; - let planObservedAt = null; - let observingPlan = false; - let planObservationTask = Promise.resolve(); - const planWatch = - name === 'patch' - ? setInterval(() => { - if (planObservedAt !== null || observingPlan) return; - observingPlan = true; - planObservationTask = readLedger(host) - .then((entry) => { - if ( - JSON.stringify(entry?.value.plan ?? null).includes( - 'parallel-plan-proof', - ) - ) - planObservedAt = Date.now(); - }) - .catch(() => {}) - .finally(() => { - observingPlan = false; - }); - }, 20) - : null; - const child = spawn( - executable, - [ - '--config-dir', - host, - '--direct-codex-chat', - project, - '测试现有临时工程的宿主执行许可;只按fixture合同操作,不生成平台素材。', - ], - { - cwd: directory, - env, - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'], - }, - ); - child.stdout.on('data', (data) => { - stdout = (stdout + data.toString('utf8')).slice(-2 * 1024 * 1024); - }); - child.stderr.on('data', (data) => { - stderr = (stderr + data.toString('utf8')).slice(-2 * 1024 * 1024); - }); - // restart:等工具真的开始跑(marker 落盘)再杀进程,保证"回合进行中终止"是真事, - // 而不是在模型请求还没回来时就把它掐掉。 - const restartKillWatch = - name === 'restart' - ? (async () => { - const marker = path.join(project, 'started.txt'); - const deadline = Date.now() + 30_000; - while (Date.now() < deadline && !(await exists(marker))) { - await sleep(20); - } - assert(await exists(marker), 'restart: tool never started'); - // 留一点时间让这一轮的工具卡片落进项目历史,再终止进程。 - await sleep(400); - child.kill(); - })() - : null; - // running:进程存活期间轮询宿主账本,抓一份「正在跑」的快照——界面重进时判断 - // "这一轮还在跑、可以恢复/终止"读的就是这份事实。 - let runningLedgerSnapshot = null; - const runningSnapshotWatch = - name === 'running' - ? (async () => { - const deadline = Date.now() + 30_000; - while (Date.now() < deadline && !runningLedgerSnapshot) { - const snapshot = await readLedger(host).catch(() => null); - if ( - snapshot && - Object.keys(snapshot.value.active ?? {}).length > 0 - ) { - runningLedgerSnapshot = snapshot.value; - } - await sleep(25); - } - })() - : null; - const timeout = setTimeout(() => child.kill(), 90_000); - const exit = await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => resolve({ code, signal })); - }).finally(() => clearTimeout(timeout)); - if (planWatch) clearInterval(planWatch); - if (restartKillWatch) await restartKillWatch; - if (runningSnapshotWatch) await runningSnapshotWatch; - await planObservationTask; - server.closeAllConnections(); - await new Promise((resolve) => server.close(resolve)); - if (name === 'deadline') await sleep(9_000); - const ledger = await readLedger(host); - const dispatchEvents = mcpDispatch - ? (await fs.readFile(mcpDispatch, 'utf8').catch(() => '')) - .trim() - .split('\n') - .filter(Boolean) - .map(JSON.parse) - : []; - const dispatched = dispatchEvents.filter( - (entry) => entry.phase === 'start' && entry.method === 'tools/call', - ); - const intervals = dispatched.map((entry) => ({ - id: entry.id, - start: entry.at, - end: dispatchEvents.find( - (event) => event.id === entry.id && event.phase === 'end', - )?.at, - })); - const overlapMs = - intervals.length === 2 && intervals.every((entry) => entry.end) - ? Math.min(...intervals.map((entry) => entry.end)) - - Math.max(...intervals.map((entry) => entry.start)) - : null; - const nativeIntervals = []; - if (name === 'native') - for (const id of ['native-a', 'native-b']) { - const entries = ( - await fs - .readFile(path.join(project, id + '.jsonl'), 'utf8') - .catch(() => '') - ) - .trim() - .split('\n') - .filter(Boolean) - .map(JSON.parse); - nativeIntervals.push({ - id, - start: entries.find((entry) => entry.phase === 'start')?.at, - end: entries.find((entry) => entry.phase === 'end')?.at, - }); - } - const nativeOverlapMs = - nativeIntervals.length === 2 && nativeIntervals.every((entry) => entry.end) - ? Math.min(...nativeIntervals.map((entry) => entry.end)) - - Math.max(...nativeIntervals.map((entry) => entry.start)) - : null; - const resourceIntervals = dispatchEvents - .filter( - (entry) => - entry.phase === 'start' && - entry.method.startsWith('resources/') && - entry.at >= (requests[1]?.at ?? Infinity), - ) - .map((entry) => ({ - id: entry.id, - method: entry.method, - start: entry.at, - end: dispatchEvents.find( - (event) => event.id === entry.id && event.phase === 'end', - )?.at, - })); - const resourceOverlaps = Object.fromEntries( - ['resources/list', 'resources/templates/list', 'resources/read'].map( - (method) => { - const matching = resourceIntervals.filter( - (entry) => entry.method === method, - ); - return [ - method, - matching.length === 2 && matching.every((entry) => entry.end) - ? Math.min(...matching.map((entry) => entry.end)) - - Math.max(...matching.map((entry) => entry.start)) - : null, - ]; - }, - ), - ); - const report = { - scenario: name, - directory, - exit, - requests, - httpArrivals, - fixtureErrors, - ledger: ledger?.value, - runningLedger: runningLedgerSnapshot, - responses, - dispatched, - dispatchEvents, - intervals, - overlapMs, - mcpReadOnlyHint: mcpDispatch ? name === 'mcp' : undefined, - nativeIntervals, - nativeOverlapMs, - resourceIntervals, - resourceOverlaps, - planObservedAt, - patchModifiedAt: await fs.stat(path.join(project, 'patch-proof.txt')).then( - (stat) => stat.mtimeMs, - () => null, - ), - stdout, - stderr, - markers: { - ready: await exists(path.join(project, 'marker.txt')), - forbidden: await exists(path.join(project, 'forbidden.txt')), - started: await exists(path.join(project, 'started.txt')), - late: await exists(path.join(project, 'late.txt')), - }, - }; - await fs.writeFile( - path.join(directory, 'result.json'), - JSON.stringify(report, null, 2), - ); - assert( - requests.length > 0, - name + ': AGC did not reach the loopback fixture', - ); - assert( - requests.every((request) => request.parallelToolCalls === true), - name + - ': Direct Responses requests must allow multiple tool calls without changing the selected model', - ); - assert(ledger, name + ': missing host-authoritative ledger'); - if (name === 'restart') { - await assertRestartKeepsPartialHistory(project, ledger.value, exit); - } else { - assert.equal(exit.code, 0, name + ': CLI failed; inspect result.json'); - assert.equal( - report.markers.forbidden, - false, - name + ': effect ran after host terminal', - ); - assert.equal( - ledger.value.usedPasses, - 1, - name + ': ordinary commands must share one pass', - ); - assert.equal( - ledger.value.phase, - ['completed', 'patch', 'native-session', 'running'].includes(name) - ? 'completed' - : 'exhausted', - ); - assert.equal( - ledger.value.executorStopped, - true, - name + ': missing full executor exit proof', - ); - if (name === 'running') { - assert( - runningLedgerSnapshot, - 'running: host never published a live active lease while the process was alive', - ); - assert.equal( - runningLedgerSnapshot.phase, - 'working', - 'running: live turn must read as working, not terminal', - ); - const leases = Object.values(runningLedgerSnapshot.active ?? {}); - assert.equal( - leases.length, - 1, - 'running: exactly one live execution lease expected', - ); - assert.equal(leases[0].kind, 'execute'); - assert.equal( - runningLedgerSnapshot.executorStopped, - false, - 'running: live turn must not claim its executor stopped', - ); - const history = await fs - .readFile( - path.join(project, '.agent/conversations/project.jsonl'), - 'utf8', - ) - .catch(() => ''); - // 用户消息与最终助手回复由**前端**(`append_direct_project_conversation_message`)写入, - // CLI / 无前端宿主只落宿主侧条目:中间文本 → 工具卡片 → 工具回执。 - const rows = history.split('\n').filter(Boolean); - const order = [ - rows.findIndex((row) => row.includes('RUNNING-PARTIAL-TEXT')), - rows.findIndex( - (row) => row.includes('running-slow') && row.includes('"arguments"'), - ), - rows.findIndex( - (row) => row.includes('running-slow') && row.includes('"output"'), - ), - ]; - assert( - order.every((index) => index >= 0) && - order[0] < order[1] && - order[1] < order[2], - 'running: partial text → tool card → tool output must stay in order; got ' + - JSON.stringify({ order, rows: rows.length }), - ); - } - } - if (name === 'completed') assert.equal(report.markers.ready, true); - if (isMcp) { - assert.equal( - dispatched.length, - 2, - 'untrusted readonly hints cannot authorize a post-terminal MCP call', - ); - assert(dispatched.every((entry) => entry.arguments.step === 1)); - assert( - overlapMs > 0, - name + - ': real Codex MCP calls ran serially; inspect start/end evidence before changing dispatch policy', - ); - } - if (name === 'deadline') { - assert.equal( - report.markers.started, - true, - 'must prove the real child started before the cutoff', - ); - assert.equal( - report.markers.late, - false, - 'turn interruption alone did not stop the child', - ); - } - if (name === 'native') - assert( - nativeOverlapMs > 0, - 'independent native write commands must overlap', - ); - if (name === 'native-resources') - for (const [method, overlap] of Object.entries(resourceOverlaps)) - assert( - overlap > 0, - method + ': real native resource reads did not overlap', - ); - if (name === 'patch') { - const catalogue = requests[0].toolCatalogue; - assert( - !catalogue.some((tool) => - ['apply_patch', 'update_plan', 'request_user_input'].includes( - tool.name, - ), - ), - 'serial native registrations must be absent', - ); - const owned = - catalogue.find((tool) => tool.name === 'mcp__agc_tools')?.tools ?? []; - for (const tool of ['agc_apply_patch', 'agc_update_plan']) - assert(owned.some((entry) => entry.name === tool)); - assert.equal( - intervals.length, - 1, - 'exactly one slow third-party call must execute', - ); - assert.equal( - await fs.readFile(path.join(project, 'patch-proof.txt'), 'utf8'), - 'parallel patch proof\n', - ); - assert.equal( - await fs.readFile(path.join(project, 'mcp-done.txt'), 'utf8'), - 'done', - ); - for (const [action, at] of Object.entries({ - patch: report.patchModifiedAt, - plan: planObservedAt, - })) - assert( - at > intervals[0].start && at < intervals[0].end, - action + ' did not complete while the slow MCP call was running', - ); - } - if (name === 'native-session') { - const execOutput = requests - .flatMap((entry) => entry.inputs ?? []) - .filter((item) => item.call_id === 'session') - .map((item) => { - try { - return toolOutputText(JSON.parse(item.raw).output); - } catch { - return ''; - } - }) - .join('\n'); - assert( - /Process running with session ID \d+/.test(execOutput), - 'exec_command must return a unified exec session id', - ); - assert( - await exists(path.join(project, 'session.txt')), - 'write_stdin polling must let the unified exec session finish', - ); - assert.equal( - ledger?.value?.phase, - 'completed', - 'host acceptance must complete after the session artifact appears', - ); - } - console.log( - JSON.stringify({ - scenario: name, - passed: true, - result: path.join(directory, 'result.json'), - }), - ); -} - -const failures = []; -for (const name of cases) { - try { - await runScenario(name); - } catch (error) { - failures.push({ scenario: name, error: String(error) }); - console.error(JSON.stringify(failures.at(-1))); - } -} -assert.equal( - failures.length, - 0, - 'production fixture failures: ' + JSON.stringify(failures), -); diff --git a/apps/ai-game-creator-shell/scripts/macos-bundle-policy.mjs b/apps/ai-game-creator-shell/scripts/macos-bundle-policy.mjs new file mode 100644 index 000000000..34cae1aa1 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/macos-bundle-policy.mjs @@ -0,0 +1,46 @@ +/** + * macOS 安装包内容策略。 + * + * `check-macos-bundle.mjs` 会扫描随包 resources。Node runtime 与 Claude Agent SDK + * sidecar 都需要 `node_modules` 形式的目录,但其它构建残留或第三方依赖不能被 + * 静默带入安装包,因此这里只放行两棵明确的生产子树。 + */ + +const NODE_RUNTIME_NODE_MODULES = 'game-runtime/node/node_modules'; +const CLAUDE_AGENT_NODE_MODULES = 'claude-agent/node_modules'; +const CLAUDE_AGENT_SCOPE = `${CLAUDE_AGENT_NODE_MODULES}/@anthropic-ai`; + +const CLAUDE_AGENT_PACKAGE_PATTERN = + /^(?:claude-agent-sdk|claude-agent-sdk-darwin-(?:arm64|x64))(?:\/.*)?$/u; + +function isAllowedClaudeAgentNodeModulesPath(file) { + if (file === CLAUDE_AGENT_NODE_MODULES || file === CLAUDE_AGENT_SCOPE) { + return true; + } + if (!file.startsWith(`${CLAUDE_AGENT_SCOPE}/`)) { + return false; + } + return CLAUDE_AGENT_PACKAGE_PATTERN.test( + file.slice(`${CLAUDE_AGENT_SCOPE}/`.length), + ); +} + +export function isAllowedBundledNodeModulesPath(file) { + return ( + file === NODE_RUNTIME_NODE_MODULES || + file === `${NODE_RUNTIME_NODE_MODULES}/npm` || + file.startsWith(`${NODE_RUNTIME_NODE_MODULES}/npm/`) || + isAllowedClaudeAgentNodeModulesPath(file) + ); +} + +export function listForbiddenBundledResourceFiles(files) { + return files.filter( + (file) => + /(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/u.test( + file, + ) || + (/(^|\/)node_modules(\/|$)/u.test(file) && + !isAllowedBundledNodeModulesPath(file)), + ); +} diff --git a/apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs b/apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs index 6a0397bbf..1b81f67b1 100644 --- a/apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs +++ b/apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { listForbiddenBundledResourceFiles } from './macos-bundle-policy.mjs'; import { assertMacosAppMatchesChannelIdentity, assertManifestArtifactMatchesExpected, @@ -8,6 +9,42 @@ import { readMacosAppInfoIdentity, } from './macos-release-identity.mjs'; +test('allows only the production node_modules subtrees in the macOS bundle', () => { + assert.deepEqual( + listForbiddenBundledResourceFiles([ + 'game-runtime/node/node_modules', + 'game-runtime/node/node_modules/npm/bin/npm-cli.js', + 'claude-agent/node_modules', + 'claude-agent/node_modules/@anthropic-ai', + 'claude-agent/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs', + 'claude-agent/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/claude', + ]), + [], + ); + assert.deepEqual( + listForbiddenBundledResourceFiles([ + 'claude-agent/node_modules/unexpected/index.mjs', + 'claude-agent/node_modules/@anthropic-ai/unexpected/index.mjs', + 'plugins/agc-cocos-editor/node_modules/leftover/index.mjs', + 'game-runtime/node/.env.local', + 'claude-agent/auth.json', + 'claude-agent/target/debug/claude', + 'claude-agent/tool.exe', + 'claude-agent/tool.dll', + ]), + [ + 'claude-agent/node_modules/unexpected/index.mjs', + 'claude-agent/node_modules/@anthropic-ai/unexpected/index.mjs', + 'plugins/agc-cocos-editor/node_modules/leftover/index.mjs', + 'game-runtime/node/.env.local', + 'claude-agent/auth.json', + 'claude-agent/target/debug/claude', + 'claude-agent/tool.exe', + 'claude-agent/tool.dll', + ], + ); +}); + const DEV_IDENTITY = { productName: '陶泥儿开发版', identifier: 'world.genarrative.ai-game-creator', 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..7cc06d82a 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([ @@ -225,7 +275,9 @@ function isDirectModuleExecution() { 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/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 6940945d5..2e8e3ee12 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -29,12 +29,9 @@ mod direct_project_context; mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; -mod direct_thread_manager; -mod direct_thread_wire; mod direct_tool_bridge; mod direct_tool_calls; mod direct_tools_mcp; -mod direct_turn_accept; mod direct_turn_error; mod direct_turn_failure; mod direct_turn_stream; @@ -49,6 +46,8 @@ mod runtime_protocol; mod runtime_state; mod runtime_tools; mod skill_pack; +mod thread_manager; + use claude_code_cli::*; pub(crate) use claude_code_cli::{ cancel_direct_claude_code_turn_at, direct_game_creator_claude_code_chat_at, @@ -58,7 +57,7 @@ pub(crate) use claude_code_cli::{ use codex_app_server::*; pub(crate) use codex_app_server::{ cancel_direct_codex_turn_at, direct_game_creator_codex_chat_at, - direct_game_creator_home_codex_chat, direct_thread_id_for_project, DirectTurnCancelView, + direct_game_creator_home_codex_chat, thread_id_for_project, DirectTurnCancelView, }; use codex_cli::*; pub(crate) use codex_cli::{ @@ -71,12 +70,9 @@ pub(crate) use direct_codex_user_item::*; pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; -pub(crate) use direct_thread_manager::*; -pub(crate) use direct_thread_wire::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; -pub(crate) use direct_turn_accept::*; pub(crate) use direct_turn_error::*; pub(crate) use direct_turn_failure::*; pub(crate) use direct_turn_stream::*; @@ -91,6 +87,10 @@ pub(crate) use runtime_protocol::*; pub(crate) use runtime_state::*; pub(crate) use runtime_tools::*; pub(crate) use skill_pack::*; +pub(crate) use thread_manager::dispatch::*; +pub(crate) use thread_manager::queue::*; +pub(crate) use thread_manager::wire::*; +pub(crate) use thread_manager::*; pub(crate) fn shutdown_game_creator_codex_app_servers() -> Result<(), String> { shutdown_game_creator_codex_app_servers_impl() 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 a6ad51e1f..4dee3f105 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 @@ -304,7 +304,8 @@ pub(crate) fn game_creator_claude_code_cli_version_identity() -> Result String { +pub(crate) fn thread_id_for_project(root: &std::path::Path) -> String { let Ok((canonical_root, _)) = resolve_direct_codex_project_authority(root) else { return root.to_string_lossy().into_owned(); }; 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 6476dd583..3c00d1410 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 @@ -16,7 +16,7 @@ use process_tree::{OwnedProcessTree, ProcessTreeExitProof}; mod direct_project_identity; mod execution; mod model_catalog; -pub(crate) use direct_project_identity::direct_thread_id_for_project; +pub(crate) use direct_project_identity::thread_id_for_project; use direct_project_identity::*; use execution::ExecutionAdapter; @@ -631,7 +631,7 @@ enum CodexTurnEvent { params: serde_json::Value, }, Request { - kind: DirectThreadRequestKind, + kind: ThreadRequestKind, params: serde_json::Value, }, RawItem(serde_json::Value), @@ -808,8 +808,8 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat fn direct_thread_event_item( root: &std::path::Path, item: &serde_json::Value, -) -> Option { - direct_thread_item_from_value(root, item, direct_tool_call_now_ms()) +) -> Option { + thread_item_from_value(root, item, direct_tool_call_now_ms()) } /// 运行态条目投影:Codex 回显的用户消息整条跳过。 @@ -824,17 +824,17 @@ fn direct_thread_event_item( fn direct_thread_visible_item( root: &std::path::Path, item: &serde_json::Value, -) -> Option { +) -> Option { if is_direct_project_codex_user_item(item) { return None; } direct_thread_event_item(root, item) } -/// 下发本轮的开口用户条目:接单之后、起 codex 之前的第一条运行态条目。 +/// 下发本轮的开口用户条目:放行之后、起 codex 之前的第一条运行态条目。 /// /// **顺序是这条通道的全部意义**:整轮里任何失败说明都靠"属于哪一轮"归位,而归属只认这一轮的 -/// 开口用户条目。发点在 `turn/start` 之后时,"接单到 `turn/start` 之间"的失败(连不上 +/// 开口用户条目。发点在 `turn/start` 之后时,"放行到 `turn/start` 之间"的失败(连不上 /// app-server、执行器未通过验收、历史注入失败)没有用户条目可以挂,说明会按位置落进**上一轮** /// 的分区里:界面显示成"错误在用户消息上面",上一轮还顶替本轮显示耗时,本轮的用户气泡再自成 /// 一个 0.0 秒的假回合。 @@ -844,14 +844,14 @@ fn direct_thread_visible_item( pub(crate) fn emit_direct_thread_user_item( root: &std::path::Path, item: &serde_json::Value, -) -> Option { +) -> Option { let entry_item = direct_thread_event_item(root, item)?; - // 条目时间是落盘 / 观测时间。前端乐观用户气泡已删(ADR「DirectProject命令接单化」后续更新), + // 条目时间是落盘 / 观测时间。前端乐观用户气泡已删(ADR「DirectProject命令入队化」), // 所以这就是界面显示这条用户消息的唯一时间口径:它晚于用户按下发送,但不再有第二份更早的时间。 let at = entry_item.at(); - append_direct_thread_event( - &direct_thread_id_for_project(root), - DirectThreadEvent::item_completed(entry_item.clone(), at), + append_thread_event( + &thread_id_for_project(root), + ThreadEvent::item_completed(entry_item.clone(), at), ); Some(entry_item) } @@ -1080,21 +1080,21 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static } } -fn direct_codex_request_event_type(method: &str) -> Option { +fn direct_codex_request_event_type(method: &str) -> Option { match method { "item/fileChange/requestApproval" | "item/commandExecution/requestApproval" - | "item/permissions/requestApproval" => Some(DirectThreadRequestKind::ApprovalRequested), + | "item/permissions/requestApproval" => Some(ThreadRequestKind::ApprovalRequested), "item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => { - Some(DirectThreadRequestKind::AskRequested) + Some(ThreadRequestKind::AskRequested) } _ => None, } } -fn direct_codex_resolution_event_type(method: &str) -> Option { +fn direct_codex_resolution_event_type(method: &str) -> Option { match method { - "serverRequest/resolved" => Some(DirectThreadRequestKind::RequestResolved), + "serverRequest/resolved" => Some(ThreadRequestKind::RequestResolved), _ => None, } } @@ -1197,10 +1197,10 @@ fn direct_codex_reasoning_delta_event( fn direct_codex_thread_delta_event( root: &std::path::Path, item_id: String, - kind: DirectThreadDeltaKind, + kind: ThreadDeltaKind, delta: &str, -) -> DirectThreadEvent { - DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta)) +) -> ThreadEvent { + ThreadEvent::item_delta(item_id, kind, thread_delta_text(root, delta)) } /// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。 /// @@ -3350,11 +3350,11 @@ impl CodexAppServerConnection { .map(str::trim) .filter(|turn_id| !turn_id.is_empty()) .map(str::to_string); - // 用户条目由 GUI 命令在**接单之后、起 codex 之前**落盘("落盘即接单"),这里不再重复写; + // 用户条目由放行侧在**放行之后、起 codex 之前**落盘("落盘即回合成立"),这里不再重复写; // 本函数只取它的身份,把它作为本回合的第一条运行态条目下发,前端就能用同一个 itemId 把 // "本地乐观气泡"和"历史里的同一条"合成一条。 // 没有条目但给了 `clientTurnId` 的调用方(不是 GUI 命令那条路)只拿到一份本地投影: - // 不落盘,因为落盘的时机属于接单动作,不属于这里。 + // 不落盘,因为落盘的时机属于放行动作,不属于这里。 let mut direct_turn_user_item: Option = None; if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let current_prompt = direct_codex_current_user_prompt(&request).trim(); @@ -3616,14 +3616,14 @@ impl CodexAppServerConnection { } } turn_start_guard.armed = false; - let direct_thread_id = direct_thread_id_for_project(history_root); + let direct_thread_id = thread_id_for_project(history_root); // 本轮开口用户条目的 canonical id:只从已落盘的那条条目上读身份(`id`,工具条目才用 // `call_id`),不在事件侧重造一份。拿不到就留空,让前端按"归属不可证明"处理。 let direct_turn_user_item_id = direct_turn_user_item .as_ref() - .and_then(direct_thread_item_identity); - // 逻辑回合的**边界**不在这里:开始事件由接单动作发出、兜底由接单占用对象持有 - // (`direct_turn_accept.rs`)。本轮的用户条目也不在这里下发——发点在接单之后、 + .and_then(thread_item_identity); + // 逻辑回合的**边界**不在这里:开始事件由认领动作发出、兜底由放行占用对象持有 + // (`thread_manager::dispatch`)。本轮的用户条目也不在这里下发——发点在放行之后、 // 起 codex 之前(`emit_direct_thread_user_item`),见那条注释。 // // 下面这个毫秒钟与逻辑回合无关,只服务模型终态的**完成时刻**:上游 Turn 的 @@ -3713,14 +3713,14 @@ impl CodexAppServerConnection { Some(CodexTurnEvent::AgentMessageDelta { item_id, delta }) => { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { direct_project_history.observe_delta(&item_id, &delta); - append_direct_thread_event( + append_thread_event( &direct_thread_id, // 事件自足:增量自带 item 身份与正文类别(正文 / 思考), // 前端 reducer 不允许靠猜 itemId 的来源决定 kind。 direct_codex_thread_delta_event( history_root, item_id.clone(), - DirectThreadDeltaKind::Message, + ThreadDeltaKind::Message, &delta, ), ); @@ -3755,12 +3755,12 @@ impl CodexAppServerConnection { } Some(CodexTurnEvent::ReasoningDelta { item_id, delta }) => { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - append_direct_thread_event( + append_thread_event( &direct_thread_id, direct_codex_thread_delta_event( history_root, item_id, - DirectThreadDeltaKind::Reasoning, + ThreadDeltaKind::Reasoning, &delta, ), ); @@ -3797,9 +3797,9 @@ impl CodexAppServerConnection { if let Some(entry_item) = entry_item { // `rawResponseItem/completed` 不带阶段时间,宿主处理到这条 // 通知的钟就是该阶段唯一可证明的时间。 - append_direct_thread_event( + append_thread_event( &direct_thread_id, - DirectThreadEvent::item_completed( + ThreadEvent::item_completed( entry_item, direct_tool_call_now_ms(), ), @@ -3815,9 +3815,9 @@ impl CodexAppServerConnection { .or_else(|| params.get("id").and_then(serde_json::Value::as_str)) .filter(|value| !value.is_empty()) .map(str::to_string); - append_direct_thread_event( + append_thread_event( &direct_thread_id, - DirectThreadEvent::request(kind, request_id), + ThreadEvent::request(kind, request_id), ); } } @@ -3930,11 +3930,11 @@ impl CodexAppServerConnection { { // `item/started` 的通知层带 `startedAtMs`:这是工具真正 // 开始的阶段时间,优先于条目展示时间与宿主钟。 - append_direct_thread_event( + append_thread_event( &direct_thread_id, - DirectThreadEvent::item_started( + ThreadEvent::item_started( entry_item, - direct_thread_item_event_at_ms( + thread_item_event_at_ms( ¶ms, item, false, @@ -3984,7 +3984,7 @@ impl CodexAppServerConnection { { model_terminal = Some(( status.to_string(), - direct_thread_turn_completed_at_ms( + thread_turn_completed_at_ms( turn, Some(direct_turn_started_at_ms), direct_tool_call_now_ms(), @@ -4311,7 +4311,7 @@ impl DirectTurnTerminalContext { history_root, ); // 终态走 Thread Manager 的深出口:解除这一轮的占用并写下 `turn.completed`。 - complete_direct_thread_turn( + complete_turn( &self.thread_id, terminal.event(self.completed_at, self.user_item_id.as_deref()), ); @@ -4481,7 +4481,7 @@ fn register_active_direct_codex_turn( /// 已向正在跑的回合发出中断:界面等这一轮自己的收尾复位。 pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED: &str = "interrupted"; -/// 这一轮已经没有人替它收尾,本地守卫已被兜底释放:界面必须自己复位。 +/// 这一轮已经没有人替它收尾,占用已被兜底解除:界面必须自己复位。 pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_RELEASED: &str = "released"; /// `cancel_direct_codex_turn` 的返回值:界面据此决定是自己复位,还是等回合自己收尾。 @@ -4497,12 +4497,12 @@ pub(crate) struct DirectTurnCancelView { pub(crate) client_turn_id: String, } -/// "终止"这一步要作用在哪:发中断,还是走残留守卫兜底释放。 +/// "终止"这一步要作用在哪:发中断,还是走残留回合的兜底释放。 enum DirectCodexTurnCancelTarget { /// app-server 侧还有活句柄:正常发 `turn/interrupt`。 Interrupt(Arc), /// app-server 侧已经拿不到可中断的活句柄;带上是哪种情况。 - Stale(DirectTaonierStaleGuardReason), + Stale(StaleTurnReleaseReason), } /// 终止当前项目正在运行的 Direct 回合。 @@ -4512,20 +4512,15 @@ enum DirectCodexTurnCancelTarget { /// 不动任何既有事件或命令语义。 /// /// 兜底路径:app-server 侧已经拿不到可中断的活句柄时,说明这一轮不会再有人替它收尾。 -/// 只发中断会让本地守卫(`DirectTaonierActiveInvocationGuard`)永远留在进程内,用户此后 -/// 每条消息都会被"已有另一条回合正在运行"拒绝——这正是"重进会话被堵死"的死锁形态。 -/// 这时显式释放这条守卫并把可读原因返回给界面。释放条件见 -/// [`release_stale_direct_taonier_active_invocation`] 的注释;"正在跑的是另一轮"仍然 -/// 保持原拒绝语义,什么都不释放。 +/// 只发中断会让 Thread Manager 上这一轮的占用永远解不开,这个项目此后每条消息都会被 +/// "已有另一条回合正在跑"挡在放行之外——这正是"重进会话被堵死"的死锁形态。这时显式写一条 +/// `turn.completed{aborted}` 解除占用并把可读原因返回给界面。释放条件见 +/// [`stale_turn_for_release`] 的注释;"正在跑的是另一轮"仍然保持原拒绝语义, +/// 什么都不释放。 /// -/// 兜底终态带 `userItemId`:身份取 `release_stale_direct_taonier_active_invocation` 返回的 -/// clientTurnId(客户端回合身份的唯一来源),与正常路径的开口条目 id 同一份 canonical 口径。 -/// 拿不到 clientTurnId 就留空——这一轮不会再有原生终态,猜一个身份会让前端把边界盖到别人身上。 -fn direct_stale_cancel_turn_completed_event(client_turn_id: &str) -> DirectThreadEvent { - DirectThreadEvent::turn_completed("aborted".to_string(), direct_tool_call_now_ms()) - .with_user_item_id(direct_codex_user_item_id_for_client_turn_id(client_turn_id).as_deref()) -} - +/// 兜底终态由 Thread Manager 在解除占用的同一个临界区里写下(带 `userItemId`,身份取被释放那一轮 +/// 自己的 `clientTurnId`,与放行时 `turn.started` 同一份 canonical 口径):这一轮不会再有原生终态, +/// 猜一个身份会让前端把边界盖到别人身上。 pub(crate) fn cancel_direct_codex_turn_at( root: &Path, client_turn_id: Option<&str>, @@ -4557,12 +4552,10 @@ pub(crate) fn cancel_direct_codex_turn_at( Ok((_, cancellation)) if cancellation.app_server_alive() => { DirectCodexTurnCancelTarget::Interrupt(Arc::clone(cancellation)) } - Ok(_) => { - DirectCodexTurnCancelTarget::Stale(DirectTaonierStaleGuardReason::ExecutorExited) + Ok(_) => DirectCodexTurnCancelTarget::Stale(StaleTurnReleaseReason::ExecutorExited), + Err(_) => { + DirectCodexTurnCancelTarget::Stale(StaleTurnReleaseReason::NeverReachedExecutor) } - Err(_) => DirectCodexTurnCancelTarget::Stale( - DirectTaonierStaleGuardReason::NeverReachedExecutor, - ), } }; match target { @@ -4579,16 +4572,13 @@ pub(crate) fn cancel_direct_codex_turn_at( }) } DirectCodexTurnCancelTarget::Stale(reason) => { - let released = - release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?; - // 这一轮不会再有人替它发终态事件(执行进程已退出 / 从没进执行器), - // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。 - // 走 Thread Manager 的深层出口而不是裸 append:这是**为这一轮写的终态**,占用必须 - // 同时解除,否则这个 thread 会一直被认为是"还有没收口的回合",挡住后面的接单。 - complete_direct_thread_turn( - &direct_thread_id_for_project(root), - direct_stale_cancel_turn_completed_event(&released), - ); + // 校验与释放**同一个临界区**(确实登记着一轮、身份对得上、过了启动窗口才允许判成残留; + // 占用与兜底终态要么一起落地,要么一个字都不写)。校验完了再单独释放不行:两步之间并发 + // 认领可能已经登记了新那一轮的占用,无条件解除就会清掉它。 + let released = stale_turn_for_release(root, client_turn_id, reason)?; + // 这一轮不会再有人替它收尾(占用刚被兜底解除):踢一脚让队列继续。 + // 少了这一脚,排在这条后面的待发消息要等到下一次用户动作才会被放行。 + crate::agent::kick_queue_dispatch(root); Ok(DirectTurnCancelView { outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(), message: format!( @@ -6012,10 +6002,7 @@ mod tests { fn direct_thread_message_and_reasoning_deltas_are_sanitized_before_enqueue() { let root = std::path::Path::new("/workspace/direct-project"); let raw = "key=sk-abcdefghijklmnop project=/workspace/direct-project/assets/a.png private=/root/secret.txt"; - for kind in [ - DirectThreadDeltaKind::Message, - DirectThreadDeltaKind::Reasoning, - ] { + for kind in [ThreadDeltaKind::Message, ThreadDeltaKind::Reasoning] { let event = direct_codex_thread_delta_event(root, "item-1".to_string(), kind, raw); let wire = serde_json::to_string(&event).expect("serialize direct thread delta"); assert!( @@ -6051,12 +6038,12 @@ mod tests { ]; let streamed: String = chunks .iter() - .map(|chunk| direct_thread_delta_text(root, chunk)) + .map(|chunk| thread_delta_text(root, chunk)) .collect(); let whole = chunks.concat(); assert_eq!(streamed, whole, "逐段脱敏不得吃掉段尾换行"); assert_eq!( - direct_thread_delta_text(root, &whole), + thread_delta_text(root, &whole), streamed, "逐段脱敏与整段脱敏必须同形" ); @@ -6078,7 +6065,7 @@ mod tests { "turn-1" ), Some(CodexTurnEvent::Request { - kind: DirectThreadRequestKind::RequestResolved, + kind: ThreadRequestKind::RequestResolved, .. }) )); @@ -6092,7 +6079,7 @@ mod tests { "turn-1", ), Some(CodexTurnEvent::Request { - kind: DirectThreadRequestKind::ApprovalRequested, + kind: ThreadRequestKind::ApprovalRequested, .. }) )); @@ -6170,7 +6157,7 @@ mod tests { }; let item = event_params.get("item").expect("item payload"); assert_eq!( - direct_thread_item_event_at_ms(&event_params, item, completed, 9_999), + thread_item_event_at_ms(&event_params, item, completed, 9_999), expected_at_ms, "{method} 必须用通知层的阶段时间,而不是宿主钟" ); @@ -6193,7 +6180,7 @@ mod tests { }; let turn = params.get("turn").unwrap_or(¶ms); assert_eq!( - direct_thread_turn_completed_at_ms(turn, Some(1_700_000_000_500), 9_999), + thread_turn_completed_at_ms(turn, Some(1_700_000_000_500), 9_999), 9_999, "上游只有秒级 completedAt:不采用,取宿主处理终态的毫秒钟" ); @@ -6206,41 +6193,12 @@ mod tests { "durationMs": 42_500u64, }); assert_eq!( - direct_thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), + thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), 1_700_000_043_000, "durationMs + 宿主高精度起点才派生结束" ); } - /// 取消兜底终态也要带开口用户条目身份,且身份只有一个来源:release 返回的 clientTurnId - /// 走与正常路径同一份 canonical 口径;拿不到(空 / 空白)就留空,不猜。 - #[test] - fn stale_cancel_terminal_event_keeps_opener_user_item_id_from_client_turn_id() { - let event = direct_stale_cancel_turn_completed_event("turn-0001"); - assert_eq!(event.user_item_id(), Some("direct-codex:turn-0001:user")); - assert!(event.at().is_some(), "兜底终态仍要带宿主观测时间"); - assert!(matches!( - event, - DirectThreadEvent::TurnCompleted { ref status, .. } if status == "aborted" - )); - - for missing in ["", " "] { - let event = direct_stale_cancel_turn_completed_event(missing); - assert_eq!( - event.user_item_id(), - None, - "拿不到 clientTurnId 时不得编造开口条目身份" - ); - } - - // canonical 口径与落盘侧同一份:`direct-codex:{clientTurnId}:user`。 - assert_eq!( - direct_codex_user_item_id_for_client_turn_id(" turn-0001 ").as_deref(), - Some("direct-codex:turn-0001:user") - ); - assert_eq!(direct_codex_user_item_id_for_client_turn_id(""), None); - } - /// 封口复核要求(`RepairRequired`)是控制流:这一轮还没结束,不能写出失败终态。 /// /// 反过来,真失败必须写成载荷,`kind` / `message` 从 typed 错误投影——两者以前共用 @@ -6867,7 +6825,7 @@ mod tests { let stable_link = temp.path().join("current-project"); symlink(&project, &stable_link).expect("project symlink"); - let canonical = direct_thread_id_for_project(&project); + let canonical = thread_id_for_project(&project); assert_eq!( canonical, project @@ -6877,7 +6835,7 @@ mod tests { "线程 id 就是 canonical 路径" ); assert_eq!( - direct_thread_id_for_project(&stable_link), + thread_id_for_project(&stable_link), canonical, "符号链接必须归一到同一个线程 id" ); @@ -6889,7 +6847,7 @@ mod tests { "前提:manifest 读不到时权威身份确实会失败" ); assert_eq!( - direct_thread_id_for_project(&stable_link), + thread_id_for_project(&stable_link), canonical, "manifest 读失败不能把线程 id 退回调用方原始字符串" ); @@ -8218,17 +8176,9 @@ done "id": "direct-codex:turn-0001:user", "content": [{ "type": "input_text", "text": "请创建菜单" }] }); - let thread_id = direct_thread_id_for_project(&project); - let bootstrap = crate::agent::subscribe_direct_thread(&thread_id); - let _active_invocation = - crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") - .expect("enter direct invocation"); - let _reservation = crate::agent::direct_turn_accept::DirectTurnReservation::accept( - &thread_id, - "turn-0001", - Some("direct-codex:turn-0001:user"), - ) - .expect("accept logical turn"); + let thread_id = thread_id_for_project(&project); + let bootstrap = crate::agent::subscribe_thread(&thread_id); + let _reservation = crate::agent::TurnReservation::accept_for_test(&thread_id, "turn-0001"); crate::agent::append_direct_project_user_message_at(&project, &user_item) .expect("persist opener user item"); // 生产入口在落盘成功、起 codex 之前就把本轮的用户条目下发(`emit_direct_thread_user_item`): @@ -8286,13 +8236,13 @@ done .await .expect("the turn must finish after the connection is reclaimed"); - let consumed = crate::agent::consume_direct_thread(&bootstrap.subscription_id) - .expect("consume events"); + let consumed = + crate::agent::consume_thread(&bootstrap.subscription_id).expect("consume events"); let terminal = consumed .events .iter() .find_map(|event| match event { - DirectThreadEvent::TurnCompleted { + ThreadEvent::TurnCompleted { status, failure, .. } => Some((status.clone(), failure.clone())), _ => None, @@ -8377,25 +8327,16 @@ done "id": "direct-codex:turn-0001:user", "content": [{"type": "input_text", "text": "请创建菜单"}] }); - let thread_id = direct_thread_id_for_project(&project); - let bootstrap = crate::agent::subscribe_direct_thread(&thread_id); + let thread_id = thread_id_for_project(&project); + let bootstrap = crate::agent::subscribe_thread(&thread_id); assert!( bootstrap.events.is_empty(), "订阅发生在回合之前,bootstrap 必须为空" ); - // 生产入口(`chat_with_game_creator_direct_codex`)在发起回合前登记本客户端的 - // 付费生成身份;工具桥在这一轮里按它绑定付费调用,这里补上同一步。 - let _active_invocation = - crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") - .expect("enter direct invocation"); - // 生产入口(`chat_with_game_creator_direct_codex`)在起 codex 之前先接单,再把用户条目落盘: + // 生产入口(`enqueue_direct_codex_turn` + 放行侧)在起 codex 之前先放行(认领时登记调用身份), + // 再把用户条目落盘: // 这里补上同一步,于是这一轮的边界仍在同一个订阅里成对出现,历史里也有那条用户消息。 - let _reservation = crate::agent::direct_turn_accept::DirectTurnReservation::accept( - &thread_id, - "turn-0001", - Some("direct-codex:turn-0001:user"), - ) - .expect("accept logical turn"); + let _reservation = crate::agent::TurnReservation::accept_for_test(&thread_id, "turn-0001"); crate::agent::append_direct_project_user_message_at(&project, &user_item) .expect("persist opener user item"); // 生产入口在落盘成功、起 codex 之前就把本轮的用户条目下发(`emit_direct_thread_user_item`): @@ -8434,8 +8375,8 @@ done .expect("run direct-project turn"); drop(observer); - let consumed = crate::agent::consume_direct_thread(&bootstrap.subscription_id) - .expect("consume events"); + let consumed = + crate::agent::consume_thread(&bootstrap.subscription_id).expect("consume events"); // 回合起止必须与开口用户条目同源:前端在「只有锚点 + 历史、运行态为空」的回合里靠这个 // 身份把边界认领给同一条用户条目,缺了它就只能隐藏未知用时。 let lifecycle_user_item_ids = consumed @@ -8444,10 +8385,10 @@ done .filter(|event| { matches!( event, - DirectThreadEvent::TurnStarted { .. } | DirectThreadEvent::TurnCompleted { .. } + ThreadEvent::TurnStarted { .. } | ThreadEvent::TurnCompleted { .. } ) }) - .map(DirectThreadEvent::user_item_id) + .map(ThreadEvent::user_item_id) .collect::>(); assert_eq!( lifecycle_user_item_ids, @@ -8461,12 +8402,13 @@ done let mut assistant_items = Vec::new(); for event in &consumed.events { let item = match event { - DirectThreadEvent::ItemStarted { item, .. } - | DirectThreadEvent::ItemCompleted { item, .. } => item, + ThreadEvent::ItemStarted { item, .. } | ThreadEvent::ItemCompleted { item, .. } => { + item + } _ => continue, }; match item { - DirectThreadItem::Message { + ThreadItem::Message { item_id, role, text, @@ -8474,7 +8416,7 @@ done } if role.as_str() == "user" => { user_items.push((item_id.clone(), text.clone())); } - DirectThreadItem::Message { text, .. } if !text.trim().is_empty() => { + ThreadItem::Message { text, .. } if !text.trim().is_empty() => { assistant_items.push(text.clone()); } _ => {} 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/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs index 66ef283b4..3762251ca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -5,8 +5,7 @@ mod validation; mod wire; pub(crate) use model::DirectCodexUserItem; -pub(crate) use validation::validate_direct_codex_user_item; pub(crate) use wire::{ direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt, - direct_codex_user_item_to_response_item, + direct_codex_user_item_to_response_item, freeze_direct_codex_user_item, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index 24c8eade9..0fbd38640 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; /// DirectProject 本轮 user input 的唯一结构化入口。 -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(tag = "type", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserItem { @@ -10,7 +10,7 @@ pub(crate) enum DirectCodexUserItem { Message(DirectCodexUserMessageItem), } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectCodexUserMessageItem { @@ -19,21 +19,28 @@ pub(crate) struct DirectCodexUserMessageItem { pub(crate) id: String, } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "lowercase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserRole { User, } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserContentPart { #[serde(rename = "input_text")] InputText { text: String }, #[serde(rename = "agc_resource_reference")] - AgcResourceReference { resource_id: String }, + AgcResourceReference { + resource_id: String, + /// 这个引用被宿主解析出来的文本:素材摘要,加上引用的 UI 设计文档展开的代码上下文。 + /// 入队冻结时写入一次,此后随条目持久化,prompt / 历史回读 / turn input 共用这一份。 + /// 缺省合法(旧历史没有它照样解析),缺省时才退回按当前 manifest 现算。 + #[serde(default, skip_serializing_if = "Option::is_none")] + resolved_text: Option, + }, #[serde(rename = "agc_skill_reference")] AgcSkillReference { name: String }, #[serde(rename = "agc_runtime_region_reference")] @@ -43,7 +50,7 @@ pub(crate) enum DirectCodexUserContentPart { AgcAttachmentReference(DirectCodexUserAttachmentReferencePart), } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectCodexUserAttachmentReferencePart { @@ -55,7 +62,7 @@ pub(crate) struct DirectCodexUserAttachmentReferencePart { pub(crate) status: String, } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectCodexUserRuntimeRegionPart { @@ -89,6 +96,7 @@ mod tests { role: DirectCodexUserRole::User, content: vec![DirectCodexUserContentPart::AgcResourceReference { resource_id: "asset-hero".to_string(), + resolved_text: None, }], id: "turn-1".to_string(), }); @@ -122,6 +130,32 @@ mod tests { assert!(error.to_string().contains("unknown field"), "{error}"); } + /// 旧历史里的资源引用没有 `resolvedText`:缺省必须合法,照样解析成 `None` 的引用。 + #[test] + fn resource_reference_without_resolved_text_still_parses() { + let item: DirectCodexUserItem = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "agc_resource_reference", + "resourceId": "asset-hero" + }], + "id": "turn-1" + })) + .expect("legacy resource reference must keep parsing"); + let DirectCodexUserItem::Message(message) = item; + match &message.content[0] { + DirectCodexUserContentPart::AgcResourceReference { + resource_id, + resolved_text, + } => { + assert_eq!(resource_id, "asset-hero"); + assert_eq!(resolved_text, &None); + } + other => panic!("expected a resource reference, got {other:?}"), + } + } + #[test] fn skill_reference_serializes_with_only_the_stable_name() { let item: DirectCodexUserItem = serde_json::from_value(json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs index f63a13da1..a14d93b63 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -37,7 +37,7 @@ pub(crate) fn validate_direct_codex_user_item( for part in &message.content { match part { DirectCodexUserContentPart::InputText { .. } => {} - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + DirectCodexUserContentPart::AgcResourceReference { resource_id, .. } => { reference_count = reference_count.saturating_add(1); validate_resource_id_and_manifest(&manifest, resource_id)?; } @@ -202,6 +202,7 @@ mod tests { assert!(content_has_meaningful_input(&[ DirectCodexUserContentPart::AgcResourceReference { resource_id: "asset-hero".to_string(), + resolved_text: None, }, ])); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index 7fb52396d..ca0837eee 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -4,8 +4,8 @@ use super::model::{ }; use super::validation::validate_direct_codex_user_item; use crate::agent::{ - read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type, - sanitize_attachment_name, GameCreationAppManifest, + sanitize_attachment_local_path, sanitize_attachment_media_type, sanitize_attachment_name, + GameCreationAppManifest, }; use crate::ui_editor::persistence::{ generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND, @@ -14,6 +14,53 @@ use crate::ui_editor::persistence::{ use serde_json::Value; use std::path::Path; +/// 入队检查产出的**冻结条目**:校验 → 把每个引用 part 的解析文本写进它自己的 +/// [`DirectCodexUserContentPart::AgcResourceReference::resolved_text`] → 判空。 +/// +/// 这是这条消息**唯一**会算片段、会写盘(UI 设计文档代码导出)的地方,也是唯一的失败出口: +/// 之后的 prompt 折叠([`direct_codex_user_item_to_prompt`])与放行都不再重算、不再读 manifest, +/// 因此也没有失败可言。冻结结果随条目一路走到历史、事件与 turn input——它们读的都是同一份文本。 +pub(crate) fn freeze_direct_codex_user_item( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + let manifest = validate_direct_codex_user_item(root, item)?; + let DirectCodexUserItem::Message(message) = item; + let mut content = Vec::with_capacity(message.content.len()); + for part in &message.content { + match part { + DirectCodexUserContentPart::AgcResourceReference { + resource_id, + resolved_text, + } => { + // 客户端不产出这个字段;它只可能来自"已冻结的条目"(旧历史缺省)。缺省才现算。 + let resolved_text = match resolved_text { + Some(text) => Some(text.clone()), + None => Some(resolved_resource_reference_text( + root, + &manifest, + resource_id, + )?), + }; + content.push(DirectCodexUserContentPart::AgcResourceReference { + resource_id: resource_id.clone(), + resolved_text, + }); + } + other => content.push(other.clone()), + } + } + let frozen = DirectCodexUserItem::Message(DirectCodexUserMessageItem { + role: message.role.clone(), + content, + id: message.id.clone(), + }); + if direct_codex_user_item_to_prompt(&frozen).trim().is_empty() { + return Err("DirectProject user item 不能转换为空 prompt".to_string()); + } + Ok(frozen) +} + /// 将历史中的 canonical user item 投影为 Codex `response_item` message。 /// 非 user message 的标准 Response item 原样返回;未知形状直接失败。 pub(crate) fn direct_codex_user_item_to_response_item( @@ -134,9 +181,15 @@ pub(crate) fn direct_codex_user_item_to_wire_input( for part in &message.content { let text = match part { DirectCodexUserContentPart::InputText { text } => text.clone(), - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { - resource_reference_summary(&manifest, resource_id)? - } + DirectCodexUserContentPart::AgcResourceReference { + resource_id, + resolved_text, + } => match resolved_text { + // 冻结过的条目读自己那一份:历史回放因此就是当初发出去的那条消息。 + Some(text) => text.clone(), + // 旧历史没有这份文本,退回按当前 manifest 现算(只有摘要,不产生写副作用)。 + None => resource_reference_summary(&manifest, resource_id)?, + }, DirectCodexUserContentPart::AgcSkillReference { name } => { format!("${}", name.trim()) } @@ -165,10 +218,16 @@ pub(crate) fn direct_codex_user_item_to_codex_turn_input( DirectCodexUserContentPart::InputText { text } => { input.push(serde_json::json!({ "type": "text", "text": text })); } - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + DirectCodexUserContentPart::AgcResourceReference { + resource_id, + resolved_text, + } => { input.push(serde_json::json!({ "type": "text", - "text": resource_reference_summary(&manifest, resource_id)?, + "text": match resolved_text { + Some(text) => text.clone(), + None => resource_reference_summary(&manifest, resource_id)?, + }, })); } DirectCodexUserContentPart::AgcSkillReference { name } => { @@ -201,82 +260,88 @@ pub(crate) fn direct_codex_user_item_to_codex_turn_input( Ok(Value::Array(input)) } -pub(crate) fn direct_codex_user_item_to_prompt( - root: &Path, - item: &DirectCodexUserItem, -) -> Result { - let wire = direct_codex_user_item_to_wire_input(root, item)?; +/// canonical 条目到 prompt 的**纯折叠**:零 IO、零校验、无失败出口。 +/// +/// 它只读条目自己的事实——文本 part 的正文与引用 part 的 `resolved_text`(入队时 +/// [`freeze_direct_codex_user_item`] 冻结,随条目持久化)。条目里没有引用之外的东西要算, +/// 所以这里也不再需要 root。调用方要保证条目是冻结过的;缺省 `resolved_text` 的旧条目 +/// 走不了这一条(历史回读用 [`direct_codex_user_item_to_response_item`])。 +pub(crate) fn direct_codex_user_item_to_prompt(item: &DirectCodexUserItem) -> String { let DirectCodexUserItem::Message(message) = item; - let mut prompt = wire - .as_array() - .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())? - .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::(); - if let Some(code_context) = render_ui_design_code_context(root, message)? { - prompt.push('\n'); - prompt.push_str(&code_context); + let mut prompt = String::new(); + for part in &message.content { + match part { + DirectCodexUserContentPart::InputText { text } => prompt.push_str(text), + DirectCodexUserContentPart::AgcResourceReference { resolved_text, .. } => { + if let Some(text) = resolved_text { + prompt.push_str(text); + } + } + DirectCodexUserContentPart::AgcSkillReference { name } => { + prompt.push_str(&format!("${}", name.trim())); + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + prompt.push_str(&runtime_region_summary(reference)); + } + DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + prompt.push_str(&attachment_reference_summary(reference)); + } + } } - if prompt.trim().is_empty() { - return Err("DirectProject user item 不能转换为空 prompt".to_string()); - } - Ok(prompt) + prompt } -/// 本轮 prompt 的 UI 设计文档引用上下文:复用 UI Editor 代码导出,把带文档注释的 -/// JS 片段路径交给模型;生成失败只追加原始错误,不阻断本轮引用,其它引用继续处理。 +/// 一个资源引用 part 的解析文本:素材摘要,加上引用的 UI 设计文档展开的代码上下文。 /// -/// 只在生成本轮 prompt 时展开:历史 item 回读走 `direct_codex_user_item_to_response_item` -/// 的纯投影,不得在这里产生项目写副作用。 -fn render_ui_design_code_context( +/// 只在 [`freeze_direct_codex_user_item`] 里现算一次;算出来的就是这一 part 此后唯一的文本。 +fn resolved_resource_reference_text( root: &Path, - message: &DirectCodexUserMessageItem, -) -> Result, String> { - let referenced_ids = message - .content - .iter() - .filter_map(|part| match part { - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { - Some(resource_id.trim()) - } - _ => None, - }) - .collect::>(); - if referenced_ids.is_empty() { - return Ok(None); + manifest: &GameCreationAppManifest, + resource_id: &str, +) -> Result { + let mut text = resource_reference_summary(manifest, resource_id)?; + if let Some(code_context) = ui_design_code_context_for(root, manifest, resource_id) { + text.push('\n'); + text.push_str(&code_context); } - let manifest = read_manifest_for_project(root)?; - let mut lines = Vec::new(); - for resource_id in referenced_ids { - let is_ui_design_doc = manifest.assets.iter().any(|asset| { - asset.id == resource_id - && asset.kind == UI_DESIGN_DOC_ASSET_KIND - && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE - }); - if !is_ui_design_doc { - continue; - } - lines.push( - match generate_ui_design_code_at(GenerateUiDesignCodeInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: manifest.project_id.clone(), - asset_id: resource_id.to_string(), - }) { - Ok(result) => format!( - prompt_text!("projectContext.uiDesign.codeContext"), - result.relative_path - ), - Err(error) => format!( - prompt_text!("projectContext.uiDesign.generationErrorContext"), - error = error - ), - }, - ); + Ok(text) +} + +/// 引用的 UI 设计文档上下文:复用 UI Editor 代码导出,把带文档注释的 JS 片段路径交给模型; +/// 生成失败只带回原始错误,不阻断本轮引用。非 UI 设计文档返回 `None`。 +/// +/// 这是整条链上唯一会写盘(生成 `ui/generated-*.js`)的投影处,因此只允许在入队冻结时调用。 +/// 历史回读走 `direct_codex_user_item_to_response_item` 的纯投影,不得在这里产生项目写副作用。 +fn ui_design_code_context_for( + root: &Path, + manifest: &GameCreationAppManifest, + resource_id: &str, +) -> Option { + let resource_id = resource_id.trim(); + let is_ui_design_doc = manifest.assets.iter().any(|asset| { + asset.id == resource_id + && asset.kind == UI_DESIGN_DOC_ASSET_KIND + && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE + }); + if !is_ui_design_doc { + return None; } - if lines.is_empty() { - return Ok(None); - } - Ok(Some(lines.join("\n"))) + Some( + match generate_ui_design_code_at(GenerateUiDesignCodeInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id.clone(), + asset_id: resource_id.to_string(), + }) { + Ok(result) => format!( + prompt_text!("projectContext.uiDesign.codeContext"), + result.relative_path + ), + Err(error) => format!( + prompt_text!("projectContext.uiDesign.generationErrorContext"), + error = error + ), + }, + ) } #[cfg(test)] @@ -284,7 +349,7 @@ mod tests { use super::{ direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input, - validate_direct_codex_user_item, + freeze_direct_codex_user_item, validate_direct_codex_user_item, }; use crate::agent::direct_codex_user_item::model::DirectCodexUserItem; use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE; @@ -375,8 +440,8 @@ mod tests { let item: super::DirectCodexUserItem = serde_json::from_value(user_item_with_resource_reference(&asset_id)) .expect("canonical user item"); - let prompt = - direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); + let prompt = direct_codex_user_item_to_prompt(&frozen); assert!( prompt.contains(&format!( "[素材引用 resourceId={asset_id};项目路径=ui/design.json]" @@ -387,6 +452,14 @@ mod tests { prompt.contains("请先阅读生成的带有文档的代码片段: ui/generated-"), "{prompt}" ); + // 解析文本随条目持久化:历史回放读的就是这一份,不再重算、不再写盘。 + let super::DirectCodexUserItem::Message(message) = &frozen; + match &message.content[0] { + super::DirectCodexUserContentPart::AgcResourceReference { resolved_text, .. } => { + assert_eq!(resolved_text.as_deref(), Some(prompt.as_str())); + } + other => panic!("expected a frozen resource reference, got {other:?}"), + } } #[test] @@ -418,8 +491,8 @@ mod tests { let item: super::DirectCodexUserItem = serde_json::from_value(user_item_with_resource_reference(&asset_id)) .expect("canonical user item"); - let prompt = - direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); + let prompt = direct_codex_user_item_to_prompt(&frozen); assert!(prompt.contains("素材引用 resourceId="), "{prompt}"); assert!(prompt.contains("生成代码遇到错误"), "{prompt}"); } @@ -444,8 +517,8 @@ mod tests { ], })) .expect("canonical user item"); - let prompt = - direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); + let prompt = direct_codex_user_item_to_prompt(&frozen); let ui_doc_at = prompt .find(&failing_ui_doc_id) @@ -477,8 +550,8 @@ mod tests { let item: super::DirectCodexUserItem = serde_json::from_value(user_item_with_resource_reference(&asset_id)) .expect("canonical user item"); - let prompt = - direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); + let prompt = direct_codex_user_item_to_prompt(&frozen); assert!(prompt.contains("素材引用 resourceId="), "{prompt}"); assert!( !prompt.contains("请先阅读生成的带有文档的代码片段"), @@ -518,6 +591,42 @@ mod tests { assert_ne!(projected["content"][0]["type"], "text"); } + /// 历史回读读条目自己的 `resolvedText`:引用被冻结过就是当初那条消息,没有就退回当前 manifest 的摘要。 + #[test] + fn history_projection_reads_the_persisted_resolved_text() { + let root = prompt_context_project(); + let asset_id = register_fixture_asset( + root.path(), + "assets/hero.png", + GameCreationAppAssetKind::Character, + "image/png", + ); + for resolved_text in [None, Some("冻结时的解析文本".to_string())] { + let mut reference = json!({ + "type": "agc_resource_reference", + "resourceId": asset_id, + }); + if let Some(text) = &resolved_text { + reference["resolvedText"] = json!(text); + } + let item = json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [reference], + }); + let projected = direct_codex_user_item_to_response_item(root.path(), &item) + .expect("history projection"); + let text = projected["content"][0]["text"].as_str().expect("text"); + assert_eq!( + text, + resolved_text.unwrap_or_else(|| format!( + "[素材引用 resourceId={asset_id};项目路径=assets/hero.png]" + )) + ); + } + } + #[test] fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() { let root = tempfile::tempdir().expect("temp project"); @@ -541,7 +650,7 @@ mod tests { assert_eq!(projected["content"], item["content"]); let canonical = serde_json::from_value(item).expect("canonical user item"); assert_eq!( - direct_codex_user_item_to_prompt(root.path(), &canonical).expect("multiline prompt"), + direct_codex_user_item_to_prompt(&canonical), "你好\n第二段\n " ); } @@ -603,11 +712,9 @@ mod tests { "type": "message", "role": "user", "id": "turn-1:user", "content": content }); let canonical = serde_json::from_value(item.clone()).expect("canonical user item"); - assert_eq!( - direct_codex_user_item_to_prompt(root.path(), &canonical) - .expect("reference prompt"), - expected_prompt - ); + let frozen = freeze_direct_codex_user_item(root.path(), &canonical) + .expect("reference freeze"); + assert_eq!(direct_codex_user_item_to_prompt(&frozen), expected_prompt); let projected = direct_codex_user_item_to_response_item(root.path(), &item) .expect("reference history projection"); assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs index 6ca019902..7e279b83e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs @@ -432,7 +432,7 @@ pub(super) async fn begin( let root = root.to_path_buf(); let prompt_hash = hash(prompt.as_bytes()); let session = tokio::task::spawn_blocking(move || { - let turn = super::direct_taonier_active_invocation_id_at(&root)?; + let turn = super::active_turn_id_at(&root)?; let host = crate::game_creator_runtime_config_dir() .ok_or("direct-execution-host: 需要客户端私有配置目录,CLI 请提供 --config-dir")?; open_with_analytics_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs index 0b85cef6a..55c58ee63 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs @@ -53,7 +53,7 @@ fn context_identity(root: &Path) -> Result { let manifest = read_existing_manifest_for_project(root)?; let revision = read_game_creator_agent_runtime_project_revision(root)?.revision; let canonical = root.canonicalize().map_err(|_| "项目上下文目录无法解析")?; - let active = list_direct_active_turns()?.into_iter().find(|turn| { + let active = list_active_turns()?.into_iter().find(|turn| { Path::new(&turn.project_path).canonicalize().ok().as_ref() == Some(&canonical) }); Ok(ContextIdentity { @@ -506,11 +506,7 @@ pub(super) async fn prefetch_turn_input( let scan_root = root.to_path_buf(); let expected_turn = client_turn_id.to_string(); let files = tokio::task::spawn_blocking(move || { - if direct_taonier_active_invocation_id_at(&scan_root) - .ok() - .as_deref() - != Some(expected_turn.as_str()) - { + if active_turn_id_at(&scan_root).ok().as_deref() != Some(expected_turn.as_str()) { return Vec::new(); } let candidates = [ @@ -623,14 +619,9 @@ mod tests { async fn a_replaced_active_turn_marks_the_batch_stale() { let (_temp, root) = project(); std::fs::write(root.join("code.js"), "unchanged").unwrap(); - // 身份来自逻辑回合(Thread Manager):接单才是"这一轮在跑"的唯一登记。 + // 身份来自逻辑回合(Thread Manager):放行才是"这一轮在跑"的唯一登记。 let owner = Arc::new(std::sync::Mutex::new(Some( - DirectTurnReservation::accept( - &direct_thread_id_for_project(&root), - "turn-before", - None, - ) - .unwrap(), + TurnReservation::accept_for_test(&thread_id_for_project(&root), "turn-before"), ))); let swap = Arc::clone(&owner); let result = read_batch_with( @@ -641,14 +632,10 @@ mod tests { let result = read_file(r, f, b); let mut guard = swap.lock().unwrap(); drop(guard.take()); - *guard = Some( - DirectTurnReservation::accept( - &direct_thread_id_for_project(r), - "turn-after", - None, - ) - .unwrap(), - ); + *guard = Some(TurnReservation::accept_for_test( + &thread_id_for_project(r), + "turn-after", + )); result }, ) @@ -811,14 +798,9 @@ mod tests { #[tokio::test] async fn host_prefetch_keeps_data_out_of_system_rules_and_matches_active_turn() { let (_temp, root) = project(); - // 调用身份(预取闸门)与逻辑回合(上下文身份)是两件事,生产入口两步都做。 - let _guard = DirectTaonierActiveInvocationGuard::enter(&root, "prefetch-turn").unwrap(); - let _turn = DirectTurnReservation::accept( - &direct_thread_id_for_project(&root), - "prefetch-turn", - None, - ) - .unwrap(); + // 预取闸门与上下文身份读的是同一处:Thread Manager 上这一轮的活动回合登记。 + let _turn = + TurnReservation::accept_for_test(&thread_id_for_project(&root), "prefetch-turn"); let data = prefetch_turn_input(&root, "prefetch-turn") .await .unwrap() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index f1e6679bb..e7399982a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -1,5 +1,5 @@ -use super::direct_thread_item_identity; use super::runtime_actions::acquire_game_creator_agent_runtime_project_write_lock_with_wait; +use super::thread_item_identity; use crate::config::prepare_game_creator_private_path_for_read; use crate::project::{ append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for, @@ -610,7 +610,7 @@ fn direct_project_history_anchor_id(item: &Value) -> Option { .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) - .or_else(|| direct_thread_item_identity(item)) + .or_else(|| thread_item_identity(item)) } /// 一屏历史窗口在新端(较新一侧)的锚点。 @@ -719,7 +719,7 @@ pub(crate) fn read_direct_project_history_items_slice_at( let recorded_at_ms = newest_first .iter() .filter_map(|(item, at)| { - let identity = direct_thread_item_identity(item)?; + let identity = thread_item_identity(item)?; (*at > 0).then_some((identity, *at)) }) .collect(); 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 7757024b7..42af654b8 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,14 +1,12 @@ use super::*; use base64::Engine as _; use std::collections::BTreeMap; -use std::collections::HashMap; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::Mutex; mod user_input; -pub(crate) use user_input::chat_with_game_creator_direct_codex; +pub(crate) use user_input::enqueue_direct_codex_turn; #[cfg(test)] pub(crate) use user_input::normalize_direct_client_turn_id; @@ -453,105 +451,16 @@ fn direct_taonier_regeneration_invocation_sha256(invocation_id: &str) -> String format!("{:x}", hasher.finalize()) } -#[derive(Debug)] -struct DirectTaonierActiveInvocation { - invocation_id: String, - started_at: u64, -} - -static DIRECT_TAONIER_ACTIVE_INVOCATIONS: OnceLock< - Mutex>, -> = OnceLock::new(); - -/// 一条"app-server 侧完全没有登记"的守卫,只有在存在时间超过这个量级后才允许被 -/// "终止"兜底释放。一轮 Direct 回合在进入 app-server 之前只做本地准备(读配置、 -/// 读 manifest、拼系统提示、开审计),是秒级的;超过这个窗口还没登记,说明这一轮 -/// 不可能再进入执行器,守卫是残留。 -const DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS: u64 = 60_000; - -fn direct_taonier_active_now_millis() -> u64 { - unix_millis().min(u128::from(u64::MAX)) as u64 -} - -#[derive(Debug)] -pub(crate) struct DirectTaonierActiveInvocationGuard { - root: PathBuf, - invocation_id: String, - /// Rust 侧会话保活:只在一条 Direct 回合存活期间运行,渲染层不再定时刷新。 - _session_keepalive: Option, -} - -impl DirectTaonierActiveInvocationGuard { - pub(crate) fn enter(root: &Path, invocation_id: &str) -> Result { - let root = root - .canonicalize() - .map_err(|error| DirectTurnError::ProjectRootUnanchored { - cause: error.to_string(), - })?; - let active = DIRECT_TAONIER_ACTIVE_INVOCATIONS.get_or_init(|| Mutex::new(HashMap::new())); - let mut active = active - .lock() - .map_err(|_| DirectTurnError::HostStateUnavailable { - detail: "Direct 调用身份锁已损坏".to_string(), - })?; - match active.get(&root) { - Some(existing) => { - return Err(DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: existing.invocation_id.clone(), - incoming_invocation_id: invocation_id.to_string(), - }); - } - None => { - let started_at = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis() as u64) - .unwrap_or_default(); - active.insert( - root.clone(), - DirectTaonierActiveInvocation { - invocation_id: invocation_id.to_string(), - started_at, - }, - ); - } - } - drop(active); - Ok(Self { - root, - invocation_id: invocation_id.to_string(), - _session_keepalive: crate::auth_session::spawn_client_session_keepalive(), - }) - } -} - -impl Drop for DirectTaonierActiveInvocationGuard { - fn drop(&mut self) { - let Some(active) = DIRECT_TAONIER_ACTIVE_INVOCATIONS.get() else { - return; - }; - let Ok(mut active) = active.lock() else { - return; - }; - let remove = active - .get(&self.root) - .is_some_and(|existing| existing.invocation_id == self.invocation_id); - if remove { - active.remove(&self.root); - } - drop(active); - } -} - -pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result { - let root = root - .canonicalize() - .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; - DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| "Direct 调用身份锁已损坏".to_string())? - .get(&root) - .map(|active| active.invocation_id.clone()) +/// 这个项目上正在跑的那一轮身份(`clientTurnId`),取自 **Thread Manager 的活动回合**。 +/// +/// 它是"这一轮是谁 / 在不在跑"的唯一登记:首页「运行中的项目」快照、放行闸门(有未收口回合就不认领 +/// 队首)读的都是这一处,不再另立第二份守卫表。 +/// +/// 拿不到身份时返回带前缀的错误:调用方(付费美术执行会话、工具桥、校验预约、上下文预取)都靠 +/// "拿不到身份"来拒绝"在没有回合的情况下创建 / 清理生成账本"。 +pub(crate) fn active_turn_id_at(root: &Path) -> Result { + read_turn_identity(&thread_id_for_project(root)) + .map(|identity| identity.client_turn_id) .ok_or_else(|| { format!( "{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 当前付费美术调用缺少稳定 clientTurnId,已拒绝创建或清理生成账本" @@ -559,49 +468,26 @@ pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result Result, String> { - let root = root - .canonicalize() - .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; - Ok(DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| "Direct 调用身份锁已损坏".to_string())? - .get(&root) - .map(|active| DirectActiveTurnView { - client_turn_id: active.invocation_id.clone(), - started_at: active.started_at, - })) +/// 上限用来挡"刚放行、还在本地准备(读配置 / manifest / 拼系统提示 / 开审计)的回合被终止误伤": +/// 这期间执行器侧还没有这一轮的登记,但这一轮马上就会进去,提前释放等于放开并发。 +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 DirectTaonierStaleGuardReason { +pub(crate) enum StaleTurnReleaseReason { /// app-server 侧登记着这一轮,但执行进程已经退出:这一轮不可能再有收尾。 ExecutorExited, /// app-server 侧完全没有这一轮的登记:只有过了正常启动窗口才允许释放。 NeverReachedExecutor, } -impl DirectTaonierStaleGuardReason { +impl StaleTurnReleaseReason { pub(crate) fn message(self) -> &'static str { match self { Self::ExecutorExited => "陶泥儿执行进程已退出", @@ -610,54 +496,42 @@ impl DirectTaonierStaleGuardReason { } } -/// 强制释放某项目登记的 Direct 活跃回合占用("终止"的兜底出口)。 +/// 兜底释放一个残留回合:校验与释放**在 Thread Manager 的同一个临界区里**完成,返回被释放的 +/// `clientTurnId`。 /// -/// 释放条件(四条必须同时成立,这段注释就是契约): -/// 1. 项目路径能 canonicalize,且守卫表里确实登记了这一轮; +/// 条件(三条必须同时成立,这段注释就是契约): +/// 1. 这个 thread 上确实登记着一轮(Thread Manager 的活动回合); /// 2. 传了 `expected_client_turn_id` 时必须与登记一致——绝不误伤另一条回合; -/// 3. 调用方已确认 app-server 侧没有可中断的活句柄,即 `reason` 成立; -/// 4. `reason == NeverReachedExecutor` 时,这条登记的年龄必须超过 -/// [`DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS`],排除"刚进入、还在本地准备阶段" -/// 的正常启动窗口——那种情况下这一轮马上就会去执行器,释放等于放开并发。 +/// 3. `reason == NeverReachedExecutor` 时,登记的年龄必须超过 +/// [`STALE_TURN_RELEASE_MIN_AGE_MS`],排除"刚放行、还在本地准备"的正常启动窗口。 /// -/// 移除后原守卫的 `Drop` 变成空操作(`invocation_id` 已不在表里),所以释放是幂等的; -/// 释放只影响"能否开始新回合",不动任何正在跑的回合事件。 -pub(crate) fn release_stale_direct_taonier_active_invocation( +/// 不满足任何一条都**什么都不写**(占用、事件都不变),返回可读原因;因此它天然幂等——占用已经 +/// 没了就返回"没有正在运行的回合"。这一层只负责把 [`StaleTurnRelease`] 翻成人话:判据与释放 +/// 动作都在 Thread Manager 里,因为它们必须在同一个临界区里(拆成两步会让并发认领插进中间,见 +/// [`release_stale_direct_turn`])。 +pub(crate) fn stale_turn_for_release( root: &Path, expected_client_turn_id: Option<&str>, - reason: DirectTaonierStaleGuardReason, + reason: StaleTurnReleaseReason, ) -> Result { - let root = root - .canonicalize() - .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; - let mut active = DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| "Direct 调用身份锁已损坏,无法释放".to_string())?; - let Some(existing) = active.get(&root) else { - return Err("当前项目没有正在运行的陶泥儿回合,无法终止".to_string()); - }; - if let Some(expected) = expected_client_turn_id + let expected = expected_client_turn_id .map(str::trim) - .filter(|value| !value.is_empty()) - { - if existing.invocation_id != expected { - return Err("正在运行的是另一条 Direct 客户端回合,已拒绝终止".to_string()); + .filter(|value| !value.is_empty()); + let min_age_ms = (reason == StaleTurnReleaseReason::NeverReachedExecutor) + .then_some(STALE_TURN_RELEASE_MIN_AGE_MS); + match release_stale_direct_turn(&thread_id_for_project(root), expected, min_age_ms) { + StaleTurnRelease::Released(client_turn_id) => Ok(client_turn_id), + StaleTurnRelease::NoActiveTurn => { + Err("当前项目没有正在运行的陶泥儿回合,无法终止".to_string()) } - } - if reason == DirectTaonierStaleGuardReason::NeverReachedExecutor { - let age_ms = direct_taonier_active_now_millis().saturating_sub(existing.started_at); - if age_ms < DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS { - return Err(format!( - "这一轮 Direct 客户端回合刚开始 {} 秒、还在准备中,暂不能强制释放;请稍后再试", - age_ms / 1000 - )); + StaleTurnRelease::AnotherTurnRunning => { + Err("正在运行的是另一条 Direct 客户端回合,已拒绝终止".to_string()) } + StaleTurnRelease::WithinStartupWindow { age_ms } => Err(format!( + "这一轮 Direct 客户端回合刚开始 {} 秒、还在准备中,暂不能强制释放;请稍后再试", + age_ms / 1000 + )), } - let released = existing.invocation_id.clone(); - active.remove(&root); - drop(active); - Ok(released) } fn direct_taonier_regeneration_project_id(root: &Path) -> Result { @@ -1198,8 +1072,19 @@ pub(in crate::agent) fn direct_taonier_regeneration_workflow_requires_recovery_a pub(in crate::agent) fn recover_direct_taonier_regeneration_workflow_at( root: &Path, ) -> Result { + // 锁只在**真的需要恢复**时才取:正在跑的那一轮整包重生成会整轮持有 `direct-codex-art`,入队 + // 命令若无条件去取它,整包重生成期间每条消息都要先在 worker 上同步阻塞约 1 秒(`..._with_wait` + // 是 `std::thread::sleep`),然后被 `HostStateUnavailable` 拒掉——而队列本该收下它。 + // 判据本身是只读的:没有工作流 / 状态不需要恢复时,这条路一个字都不写。 + if !direct_taonier_regeneration_workflow_requires_recovery_at(root)? { + return Ok(false); + } let _executor_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, "direct-codex-art")?; + // 判据以**锁下那一份**为准:两次读之间真的重生成可能已经推进,或者别的进程已经恢复完了。 + if !direct_taonier_regeneration_workflow_requires_recovery_at(root)? { + return Ok(false); + } let Some(workflow) = read_direct_taonier_regeneration_workflow_at(root)? else { return Ok(false); }; @@ -2148,40 +2033,26 @@ pub(crate) fn record_direct_codex_failure( recovery_hint, diagnostics_suffix, ); - // 失败也进错误上报池:命令接单化之后前端 catch 只剩"接单被拒",池不能只靠前端填。 + // 失败也进错误上报池:命令入队化之后前端 catch 只剩"入队失败",池不能只靠前端填。 // 两条通道同时上报也不会变成两条——池按 fingerprint 合并同一份文案。 let _ = crate::error_report::report_agent_runtime_error("direct-codex", &public_text); public_text } -/// 命令边界的错误文本:可留痕的调用级拒绝在这里补一份运行错误诊断(文案里不带诊断引用), -/// 其余只输出 [`DirectTurnError`] 的 `Display`。 -/// -/// 分层改成 typed 之前,这几条"宿主 / 环境事实"是在回合失败通道里被写进诊断的;分层之后它们不再 -/// 进那条通道,留痕在这里补回来。GUI 命令与 CLI 边界共用这一份,禁止在各自边界再写一套判据; -/// 回合级失败已在上游写过诊断,这里直接放行。 -pub(crate) fn direct_turn_error_boundary_text( - root: &Path, - client_turn_id: Option<&str>, - failure: DirectTurnError, -) -> String { - direct_turn_rejection(root, client_turn_id, failure).message -} - -/// 拒单边界:可留痕的调用级拒绝(宿主 / 环境事实)在这里补一份运行错误诊断,然后连同**结构化 +/// 入队失败边界:可留痕的调用级失败(宿主 / 环境事实)在这里补一份运行错误诊断,然后连同**结构化 /// 变体**一起交给前端;其余只输出 [`DirectTurnError`] 的 `Display`。 /// -/// GUI 命令与 CLI 边界共用这一份判据(CLI 只要文本,走上面的 `..._text`),禁止在各自边界再写一套。 -pub(crate) fn direct_turn_rejection( +/// 命令边界只有这一份判据,禁止在各处再写一套。 +pub(crate) fn direct_turn_enqueue_failure( root: &Path, client_turn_id: Option<&str>, failure: DirectTurnError, -) -> DirectTurnRejection { +) -> DirectTurnEnqueueFailure { if !failure.is_reportable() { - return DirectTurnRejection::new(failure); + return DirectTurnEnqueueFailure::new(failure); } let message = record_direct_codex_failure(root, &failure, client_turn_id); - DirectTurnRejection { + DirectTurnEnqueueFailure { error: failure, message, } @@ -3288,7 +3159,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( )); } let mut regeneration_workflow = if mode.regenerates_existing() { - let invocation_id = direct_taonier_active_invocation_id_at(root)?; + let invocation_id = active_turn_id_at(root)?; Some(prepare_direct_taonier_regeneration_workflow_at( root, prompt, @@ -4405,9 +4276,9 @@ pub(crate) fn build_direct_codex_system_prompt_with_creation_type( .collect()) } -/// 接单前必须成立的前置条件:目录可用、读写权限、正文非空、创建类型合法。 +/// 入队前必须成立的前置条件:目录可用、读写权限、正文非空、创建类型合法。 /// -/// GUI 命令在接单前调用(不成立就是**拒单**),CLI 入口在起回合前调用。两处共用这一份判据, +/// GUI 命令在入队侧调用(不成立就是**入队失败**),CLI 入口在起回合前调用。两处共用这一份判据, /// 不要再各自复制一遍条件。 pub(crate) fn check_direct_turn_preconditions( root: &Path, @@ -4428,40 +4299,7 @@ pub(crate) fn check_direct_turn_preconditions( .map_err(|detail| DirectTurnError::InputRejected { detail })?; Ok(()) } -pub(crate) async fn run_direct_game_creator_turn_at( - root: &Path, - prompt: &str, -) -> Result { - // The CLI entry point does not receive the GUI's clientTurnId. Still arm - // one invocation identity so an otherwise optional AGC generation tool - // cannot fail merely because the request came through the CLI. This is - // local execution identity only; it does not create a Runtime task or DAG. - let invocation_id = format!("direct-cli-{}", unix_millis()); - let _invocation = DirectTaonierActiveInvocationGuard::enter(root, &invocation_id)?; - run_direct_game_creator_turn_at_with_creation_type(root, prompt, None).await -} - -pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( - root: &Path, - prompt: &str, - creation_type: Option<&str>, -) -> Result { - // CLI 入口没有"接单"这一步(它 await 整轮,要那段回复文本),前置条件在这里自己过一遍; - // GUI 命令在同一步骤之后才接单,两边共用这一份判据。 - check_direct_turn_preconditions(root, prompt, creation_type)?; - run_direct_game_creator_turn_at_with_creation_type_and_emitter( - root, - prompt, - creation_type, - None, - None, - None, - None, - ) - .await -} - -async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( +pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( root: &Path, prompt: &str, creation_type: Option<&str>, @@ -4471,7 +4309,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( crate::analytics::contract::Context, crate::analytics::store::AnalyticsWriter, )>, - analytics_attempt_id: Option<&str>, + release_identity_generation: u64, ) -> Result { let prompt = prompt.trim(); emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); @@ -4485,13 +4323,13 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( turn_emitter, direct_user_item, capture, - analytics_attempt_id, + release_identity_generation, ) .await { Ok(reply) => Ok(reply), // 走到这里的一切失败都是**回合失败**:判据已经从"错误种类"改成"发生位置"——前置条件 - // 在接单前就查过,能到这条通道的只有接单之后的事(连接、配置、历史注入、`turn/start` + // 在入队前就查过,能到这条通道的只有放行之后的事(连接、配置、历史注入、`turn/start` // 被拒、模型与交付)。所以不再有"直通调用方"的分支。 Err(failure) => { let stage = failure.turn_failure_stage(); @@ -4693,7 +4531,7 @@ async fn run_direct_game_creator_turn_inner( crate::analytics::contract::Context, crate::analytics::store::AnalyticsWriter, )>, - analytics_attempt_id: Option<&str>, + release_identity_generation: u64, ) -> Result { let requires_contract = super::direct_delivery::requires_new_web_contract( root, @@ -5110,7 +4948,7 @@ async fn run_direct_game_creator_turn_inner( root, &ledger.project_id, metadata, - analytics_attempt_id, + release_identity_generation, crate::analytics::run::Outcome { turn_id: Some(ledger.client_turn_id.clone()), end_reason, @@ -5682,170 +5520,199 @@ mod tests { assert!(normalize_direct_client_turn_id(None).is_err()); } + /// "这一轮是谁"只有 Thread Manager 一处真相;读取是只读的,不改占用。 #[test] - fn active_client_turn_id_is_exclusive_until_the_outer_turn_finishes() { - let root = tempfile::tempdir().expect("active invocation root"); - let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0001") - .expect("first client turn"); - let duplicate = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0001") - .expect_err("same stable turn is already running"); - assert!( - matches!( - &duplicate, - DirectTurnError::TurnAlreadyRunning { - existing_invocation_id, - incoming_invocation_id, - } if existing_invocation_id == "client-turn-0001" - && incoming_invocation_id == "client-turn-0001" - ), - "{duplicate:?}" - ); - // 界面按 typed 变体的两个身份字段分流,不解析文案:同一条身份 != 另一条身份。 - assert_eq!( - duplicate.to_string(), - "同一轮消息仍在处理中,已拒绝并发复用同一 clientTurnId;请等它结束或点「终止」后再发送" - ); - let different = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0002") - .expect_err("different turn cannot take over the project"); - assert!( - different - .to_string() - .contains("已有另一条 Direct 客户端回合正在运行"), - "{different}" - ); - drop(first); - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0001") - .expect("lost-response replay after the original turn finishes"); - } + fn reading_the_active_turn_identity_does_not_take_over_the_occupancy() { + let root = tempfile::tempdir().expect("active turn root"); + let thread_id = thread_id_for_project(root.path()); + assert_eq!(read_turn_identity(&thread_id), None); - #[test] - fn read_direct_active_turn_reports_the_registered_turn_and_disappears_after_drop() { - let root = tempfile::tempdir().expect("active invocation root"); - assert_eq!( - read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), - None - ); - - let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-1") - .expect("first client turn"); - let running = read_direct_taonier_active_invocation_at(root.path()) - .expect("read running project") - .expect("running turn is visible to the read-only probe"); + let reservation = TurnReservation::accept_for_test(&thread_id, "client-turn-read-1"); + let running = read_turn_identity(&thread_id).expect("running turn is readable"); assert_eq!(running.client_turn_id, "client-turn-read-1"); assert!(running.started_at > 0, "{running:?}"); - // camelCase 契约:前端按 `clientTurnId` / `startedAt` 取值。 assert_eq!( - serde_json::to_value(&running).expect("serialize view"), - serde_json::json!({ - "clientTurnId": "client-turn-read-1", - "startedAt": running.started_at, - }) + active_turn_id_at(root.path()).expect("identity for the running turn"), + "client-turn-read-1" ); - // 只读探测不占有、不释放:探测之后同项目第二次进入仍然被拒。 - let duplicate = - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-2") - .expect_err("read-only probe must not take over the project"); - assert!(duplicate - .to_string() - .contains("已有另一条 Direct 客户端回合正在运行")); + // 只读探测不释放占用:认领仍然被这一轮挡着。 + assert!(claim_pending_turn(&thread_id).is_none()); - drop(first); - assert_eq!( - read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), - None - ); + drop(reservation); + assert_eq!(read_turn_identity(&thread_id), None); + assert!(active_turn_id_at(root.path()).is_err()); } + /// 兜底释放的前置判据:身份对得上,且"从没进执行器"必须过启动窗口;判据不过**一个字都不写**, + /// 通过则占用与兜底终态在同一个临界区里一起落地。 #[test] - fn stale_guard_release_requires_a_matching_identity_and_only_after_the_start_window() { - let root = tempfile::tempdir().expect("active invocation root"); - let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-1") - .expect("first client turn"); + fn stale_turn_release_requires_a_matching_identity_and_only_after_the_start_window() { + let root = tempfile::tempdir().expect("stale turn root"); + let thread_id = thread_id_for_project(root.path()); + let reservation = TurnReservation::accept_for_test(&thread_id, "client-turn-stale-1"); + let watcher = subscribe_thread(&thread_id); + let _ = consume_thread(&watcher.subscription_id).expect("drain bootstrap"); // ① clientTurnId 不匹配:拒绝,且不误伤正在跑的那一轮。 - let mismatch = release_stale_direct_taonier_active_invocation( + let mismatch = stale_turn_for_release( root.path(), Some("client-turn-stale-2"), - DirectTaonierStaleGuardReason::ExecutorExited, + StaleTurnReleaseReason::ExecutorExited, ) .expect_err("another turn must not be released"); assert!(mismatch.contains("另一条"), "{mismatch}"); - assert!( - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-2").is_err() - ); - // ② 刚登记、还没进执行器:正常启动窗口内不许释放(释放等于放开并发)。 - let young = release_stale_direct_taonier_active_invocation( + // ② 刚放行、还没进执行器:正常启动窗口内不许释放(释放等于放开并发)。 + let young = stale_turn_for_release( root.path(), Some("client-turn-stale-1"), - DirectTaonierStaleGuardReason::NeverReachedExecutor, + StaleTurnReleaseReason::NeverReachedExecutor, ) - .expect_err("a freshly registered turn is still starting"); + .expect_err("a freshly dispatched turn is still starting"); assert!(young.contains("暂不能强制释放"), "{young}"); - // ③ 同一条登记老过窗口:判定为残留守卫,释放后同项目可以再次进入。 - backdate_active_direct_invocation(root.path(), DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS + 1); - let released = release_stale_direct_taonier_active_invocation( + // ① ② 两条拒绝路径都**什么都没写**:占用还在,事件流上也没有任何东西。 + assert_eq!( + read_turn_identity(&thread_id).map(|turn| turn.client_turn_id), + Some("client-turn-stale-1".to_string()) + ); + assert!( + consume_thread(&watcher.subscription_id) + .expect("consume after rejected releases") + .events + .is_empty(), + "判据不过不许写任何事件" + ); + + // ③ 老过窗口:判成残留——占用与兜底终态在同一个临界区里一起落地。 + backdate_direct_active_turn_for_test(&thread_id, STALE_TURN_RELEASE_MIN_AGE_MS + 1); + let released = stale_turn_for_release( root.path(), Some("client-turn-stale-1"), - DirectTaonierStaleGuardReason::NeverReachedExecutor, + StaleTurnReleaseReason::NeverReachedExecutor, ) - .expect("stale guard is released"); + .expect("stale turn is releasable"); assert_eq!(released, "client-turn-stale-1"); - let second = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-2") - .expect("a new turn can start once the stale guard is released"); - // 释放是幂等的:原 guard 的 Drop 不会影响后来登记的那一轮。 - drop(first); - let still_running = read_direct_taonier_active_invocation_at(root.path()) - .expect("read running project") - .expect("the newer turn survives the stale guard drop"); - assert_eq!(still_running.client_turn_id, "client-turn-stale-2"); - drop(second); + assert_eq!(read_turn_identity(&thread_id), None, "占用必须一起解除"); + let events = consume_thread(&watcher.subscription_id) + .expect("consume released terminal") + .events; + assert_eq!(events.len(), 1, "兜底释放只写一条终态:{events:?}"); + let terminal = &events[0]; + assert!( + matches!( + terminal, + ThreadEvent::TurnCompleted { status, .. } if status == "aborted" + ), + "{terminal:?}" + ); + assert_eq!( + terminal.user_item_id().as_deref(), + Some("direct-codex:client-turn-stale-1:user"), + "兜底终态的身份取被释放那一轮自己的 clientTurnId" + ); + assert!(terminal.at().is_some(), "兜底终态仍要带宿主观测时间"); + + // ④ 释放之后这个 thread 立刻腾出来了(占用解除与终态同批落地):下一条能重新认领, + // 而且"执行进程已退出"不看启动窗口——刚放行也允许释放。 + let _next = TurnReservation::accept_for_test(&thread_id, "client-turn-stale-2"); + assert_eq!( + stale_turn_for_release(root.path(), None, StaleTurnReleaseReason::ExecutorExited,) + .expect("executor exited ignores the start window"), + "client-turn-stale-2" + ); + drop(reservation); + } + + /// 没有任何活动回合时,兜底释放给出可读原因,而不是静默成功。 + /// 不需要恢复时**不取**整轮任务锁。 + /// + /// 正在跑的那一轮整包重生成会整轮持有 `direct-codex-art`;入队命令的第一步就是这里,若无条件 + /// 取锁,整包重生成期间每条消息都要先在 worker 上同步阻塞约 1 秒,再被 `HostStateUnavailable` + /// 拒掉——队列本该收下它。判据本身只读,不需要恢复就一个字节都不写、一把锁都不取。 + #[test] + fn recovery_skips_the_executor_task_lock_when_nothing_needs_recovery() { + let root = tempfile::tempdir().expect("recovery lock probe"); + init_local_game_project_at(root.path(), "direct-recovery-lock-probe", "恢复不取锁") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + // 有人正持有这一轮的任务锁:等价于"整包重生成正在跑"。 + let held = + try_acquire_game_creator_agent_runtime_task_lock(root.path(), "direct-codex-art") + .expect("probe the art task lock") + .expect("hold the art task lock"); + + // ① 完全没有工作流:不需要恢复,不去取锁。 + assert!( + !recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect("an absent workflow needs no recovery") + ); + + // ② 正在正常进行中的整包重生成(InProgress、没有待恢复的替换锚点):这正是"整包重生成在跑" + // 的那一刻——不需要恢复,更**不该去取**那把整轮持有的锁。 + let in_progress = { + let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root.path(), + "direct-codex.test-in-progress-lock-probe", + ) + .expect("project write lock"); + new_direct_taonier_regeneration_workflow_locked( + root.path(), + &direct_taonier_regeneration_intent_sha256("正在跑的一套美术"), + &direct_taonier_regeneration_invocation_sha256("client-turn-in-progress-probe"), + DirectTaonierRegenerationWorkflowState::InProgress, + ) + .expect("build in-progress workflow") + }; + write_direct_taonier_regeneration_workflow_at(root.path(), &in_progress) + .expect("persist the in-progress workflow"); + assert!( + !direct_taonier_regeneration_workflow_requires_recovery_at(root.path()) + .expect("an in-progress workflow is discoverable") + ); + assert!( + !recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect("a running regeneration needs no recovery"), + "整包重生成在跑时入队不该去取整轮持有的锁" + ); + + // ③ 真需要恢复(Resetting)时仍然要取锁:拿不到就如实报错,绝不当成"恢复过了"。 + let resetting = DirectTaonierRegenerationWorkflow { + state: DirectTaonierRegenerationWorkflowState::Resetting, + ..in_progress + }; + write_direct_taonier_regeneration_workflow_at(root.path(), &resetting) + .expect("persist the resetting workflow"); + assert!( + direct_taonier_regeneration_workflow_requires_recovery_at(root.path()) + .expect("a resetting workflow is discoverable") + ); + let blocked = recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect_err("a needed recovery must still take the task lock"); + assert!(blocked.contains("direct-codex-art"), "{blocked}"); + + // ④ 锁一放开照旧恢复。 + drop(held); + assert!(recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect("resetting recovery resumes once the lock is free")); + assert_eq!( + read_direct_taonier_regeneration_workflow_at(root.path()) + .expect("read recovered workflow") + .expect("recovered workflow remains") + .state, + DirectTaonierRegenerationWorkflowState::InProgress + ); } #[test] - fn stale_guard_release_after_the_executor_exited_frees_the_project() { - let root = tempfile::tempdir().expect("active invocation root"); - let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-exited-1") - .expect("client turn"); - let released = release_stale_direct_taonier_active_invocation( - root.path(), - None, - DirectTaonierStaleGuardReason::ExecutorExited, - ) - .expect("executor exited: this guard is residue"); - assert_eq!(released, "client-turn-exited-1"); - assert_eq!( - read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), - None - ); - let _second = - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-exited-2") - .expect("a new turn can start after the residue is released"); - drop(first); - - // 没有任何登记时给出可读原因,而不是静默成功。 - let empty = tempfile::tempdir().expect("empty invocation root"); - let nothing = release_stale_direct_taonier_active_invocation( - empty.path(), - None, - DirectTaonierStaleGuardReason::ExecutorExited, - ) - .expect_err("nothing to release"); + fn stale_turn_release_without_an_active_turn_says_so() { + let root = tempfile::tempdir().expect("empty turn root"); + let nothing = + stale_turn_for_release(root.path(), None, StaleTurnReleaseReason::ExecutorExited) + .expect_err("nothing to release"); assert!(nothing.contains("没有正在运行"), "{nothing}"); } - /// 把某项目当前登记的活跃回合往前拨 `age_ms`,用于覆盖"守卫年龄"分支。 - fn backdate_active_direct_invocation(root: &Path, age_ms: u64) { - let root = root.canonicalize().expect("canonical root"); - let mut active = DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .expect("active invocation lock"); - let entry = active.get_mut(&root).expect("registered invocation"); - entry.started_at = entry.started_at.saturating_sub(age_ms); - } - #[test] fn direct_success_reply_is_persisted_once_with_the_stable_client_turn_identity() { let root = tempfile::tempdir().expect("temp dir"); @@ -6823,9 +6690,10 @@ mod tests { .expect("completed replay authority is the stable outer turn, not a resampled brief"); assert_eq!(completed_replay, persisted); - let active = - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-completed-1") - .expect("restore the same client invocation after response loss"); + let _active_turn = TurnReservation::accept_for_test( + &thread_id_for_project(root.path()), + "client-turn-completed-1", + ); let replay = ensure_direct_taonier_art_package_at( root.path(), "第一套美术但偷偷换 brief", @@ -6842,7 +6710,6 @@ mod tests { .state, DirectTaonierRegenerationWorkflowState::Completed ); - drop(active); } #[test] @@ -7735,13 +7602,14 @@ mod tests { let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let text = direct_turn_error_boundary_text( + let text = direct_turn_enqueue_failure( &root, Some("direct-codex:turn-1:user"), DirectTurnError::EnvironmentNotReady { detail: "Codex app-server 启动失败:找不到可执行文件".into(), }, - ); + ) + .message; assert!(text.contains("direct-codex-failure:v2"), "{text}"); assert!(!text.contains("详情:"), "{text}"); @@ -7761,7 +7629,7 @@ mod tests { let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let text = direct_turn_error_boundary_text(&root, None, DirectTurnError::ContentEmpty); + let text = direct_turn_enqueue_failure(&root, None, DirectTurnError::ContentEmpty).message; assert_eq!(text, "聊天内容不能为空"); assert!(!root.join(".agent/runtime/errors").exists()); @@ -7778,11 +7646,12 @@ mod tests { DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败"); let recorded = record_direct_codex_failure(&root, &failure, None); // 上游把返回串挂进 `TurnFailed.detail`,边界再见到它时只做 `Display`,不再写诊断。 - let text = direct_turn_error_boundary_text( + let text = direct_turn_enqueue_failure( &root, None, DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, recorded.clone()), - ); + ) + .message; assert_eq!(text, recorded); let entries = std::fs::read_dir(root.join(".agent/runtime/errors")) 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 39665ac14..7b7cc68d4 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 @@ -1,7 +1,7 @@ //! DirectProject 用户输入命令适配器。 //! -//! Tauri 只在这里接收前端 item,校验与 canonical→prompt 投影交给 user-item -//! 深模块,回合编排仍由父模块负责。 +//! Tauri 只在这里接收前端 item,校验与 canonical→prompt 投影交给 user-item 深模块,队列交给 +//! Thread Manager;放行在 `thread_manager::dispatch`,不在这里。 use super::*; @@ -28,60 +28,49 @@ pub(crate) fn normalize_direct_client_turn_id( Ok(client_turn_id.to_string()) } -/// DirectProject 聊天命令:**只接单**,不再 await 整轮。 +/// DirectProject 聊天命令:**只入队**,不 await、也不起回合。 /// -/// 边界文案仍只在这里生成一次(`Display`);但 `Err` 的含义收窄成**拒单**——接单成立之后的 -/// 一切失败(连不上 app-server、配置 / 凭据未就绪、历史注入失败、`turn/start` 被拒、模型与 -/// 交付失败)都由这一轮的占用对象收口成 `turn.completed` 带失败载荷,不再回到这条返回值上。 +/// 入队侧就是"这条消息能不能收下":`clientTurnId` 校验 → 工作流恢复 → 用户条目校验与冻结 +/// (引用解析文本写进条目)→ 前置条件 → 容量预判 → 工程准备,任何一步失败都是**入队失败**(typed 载荷)。全部通过 +/// 之后只做一件事——把待发消息排进队列:不登记占用、不落盘、不发回合事件、不起 codex。 /// -/// 于是"这一轮跑成什么"只有订阅事件一个来源:命令返回 `Ok` 只说明**接单成立**。可留痕的调用级 -/// 拒绝(宿主 / 环境事实)仍在边界补一份运行错误诊断,返回串不带诊断引用。 +/// 于是"这一轮跑成什么"仍然只有订阅事件一个来源:命令返回 `Ok` 只说明**入队成立**。真正的回合边界 +/// (`turn.started` / `turn.completed`)由 Thread Manager 在**放行**时写出(见 `thread_manager::dispatch`), +/// 入队失败不写用户条目、不产生任何事件。可留痕的调用级失败(宿主 / 环境事实)仍在边界补一份运行 +/// 错误诊断,返回串不带诊断引用。 /// -/// 与 CLI 的分工:CLI 入口(`cli.rs` 的 `direct-codex.chat`)**保持 await**——它要把那段回复文本 -/// 打到终端上,没有事件订阅可用;它复用同一份接单前检查与同一个命令主体,只是自己等整轮的返回值。 -/// 两个入口共用 [`direct_turn_error_boundary_text`] / [`direct_turn_rejection`],不要再各写一套判据。 +/// 它是 DirectProject 唯一的命令入口:终端入口 `--direct-codex-chat`(它要保持 await 才能把回复打到 +/// 终端上)已经退役,不要再为"手工跑一轮"新增第二条直接起回合的路径。 /// -/// 设计见 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 +/// 设计见 `docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 #[tauri::command] -pub(crate) async fn chat_with_game_creator_direct_codex( +pub(crate) async fn enqueue_direct_codex_turn( project_path: String, user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, - analytics_attempt_id: Option, -) -> Result<(), DirectTurnRejection> { +) -> Result<(), DirectTurnEnqueueFailure> { let root = Path::new(project_path.trim()); let boundary_turn_id = client_turn_id.clone(); - chat_with_game_creator_direct_codex_typed( - root, - user_item, - creation_type, - client_turn_id, - analytics_attempt_id, - ) - .await - .map_err(|failure| direct_turn_rejection(root, boundary_turn_id.as_deref(), failure)) + enqueue_direct_codex_turn_typed(root, user_item, creation_type, client_turn_id) + .await + .map_err(|failure| direct_turn_enqueue_failure(root, boundary_turn_id.as_deref(), failure)) } -/// 命令主体:全程 typed。顺序固定,**每一步失败都还是拒单**: -/// `clientTurnId` 校验 → 占用调用身份 → 工作流恢复 → 用户条目校验 → 前置条件 → 工程准备 -/// → 接单 → 落盘用户条目 → 后台起整轮。 +/// 入队侧:全程 typed,顺序固定,**每一步失败都还是入队失败**: +/// `clientTurnId` 校验 → 工作流恢复 → 用户条目校验与冻结 → 前置条件 → 容量预判 → 工程准备 → 入队。 /// -/// 这个顺序不是风格问题:接单(`DirectTurnReservation::accept`)必须在所有"接单前就能判定"的 -/// 检查之后,也必须早于用户条目落盘与 `turn/start`,否则并发拒单会晚于副作用、逻辑回合的开始 -/// 事件会排在用户消息之后。 -async fn chat_with_game_creator_direct_codex_typed( +/// 这个顺序不是风格问题:工程准备(可能建工程、铺脚手架)必须在"这条消息真的被收下"之前判定; +/// 没通过检查的东西不进队列。容量放在工程准备之前是"别为注定收不下的消息先付分钟级的代价",它 +/// 与入队那一刻的容量检查同源,只是提前一次。入队本身只做判重与容量,不再有第二种副作用——落盘、 +/// 回合事件与整轮都在放行那一侧。 +async fn enqueue_direct_codex_turn_typed( root: &Path, user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, - analytics_attempt_id: Option, ) -> Result<(), DirectTurnError> { let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; - // 占用调用身份:并发拒单要早于工程准备,避免两个请求同时改同一个项目。它只挡并发,**不是** - // 首页"运行中的项目"的来源(那张表由 Thread Manager 的逻辑回合导出),但仍必须与整轮同生 - // 共死——随任务一起搬进后台。 - let active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { DirectTurnError::HostStateUnavailable { detail: redact_agent_runtime_error( @@ -91,15 +80,19 @@ async fn chat_with_game_creator_direct_codex_typed( ), } })?; - validate_direct_codex_user_item(root, &user_item) - .map_err(|detail| DirectTurnError::InputRejected { detail })?; - let user_prompt = direct_codex_user_item_to_prompt(root, &user_item) + // 冻结是这条消息唯一的算片段 / 写盘时机:引用解析文本就此写进条目自身,放行只重投影。 + let user_item = freeze_direct_codex_user_item(root, &user_item) .map_err(|detail| DirectTurnError::InputRejected { detail })?; + let user_prompt = direct_codex_user_item_to_prompt(&user_item); check_direct_turn_preconditions(root, &user_prompt, creation_type.as_deref())?; - let canonical_user_item = - serde_json::to_value(&user_item).map_err(|error| DirectTurnError::InputRejected { - detail: error.to_string(), - })?; + let thread_id = thread_id_for_project(root); + // 容量是**最便宜**的一条入队前置条件,先判:满了就直接说满,别让一条注定收不下的消息先去做工程 + // 准备——那是分钟级的活(`npm ci` + Vite 构建),还会在磁盘上留下产物。权威判据仍然是入队那一刻 + // 临界区里的容量检查(下面 `enqueue_pending_turn`):这里只是快速失败,中间被别人的消息 + // 挤满时那一条照样拦得住。 + queue_has_room(pending_turn_count(&thread_id)).map_err(|_| DirectTurnError::QueueFull { + limit: MAX_PENDING_TURNS, + })?; // 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。 crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()) .await @@ -107,147 +100,83 @@ async fn chat_with_game_creator_direct_codex_typed( let detail = redact_agent_runtime_error(root, &error, 1800); DirectTurnError::EnvironmentNotReady { detail } })?; - // 接单:从这里开始这一轮就成立了。开始事件的身份由 `clientTurnId` 推导,**不读盘回填** - // ——开始事件发生在用户条目落盘之前,而落盘本身也可能失败。 - let thread_id = direct_thread_id_for_project(root); - let user_item_id = direct_codex_user_item_id_for_client_turn_id(&turn_id); - let reservation = DirectTurnReservation::accept(&thread_id, &turn_id, user_item_id.as_deref())?; - // 落盘即接单:接单成功就必须在历史里留下这条用户消息,哪怕这一轮随后失败。 - if let Err(error) = append_direct_project_user_message_at(root, &canonical_user_item) { - // 这一轮**已经接单**,所以收口只能走占用对象:写出失败终态(事件流里的那条失败说明就是 - // 界面唯一一份解释),然后返回 `Ok`——命令的 `Err` 只表示**拒单**,回到那里会让同一个失败 - // 同时从事件与横幅两条通道下发,也会让前端把"已经开始的回合"读成"没开始"。 - // 不继续起整轮:历史是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口 - // 用户消息的助手回复,而且失败会被静默掉。 - let failure = DirectTurnError::EnvironmentNotReady { - detail: redact_agent_runtime_error( - root, - &format!("写入本项目对话历史失败:{error}"), - 600, - ), - }; - reservation.finish_if_unfinished(DirectTurnTerminal::failed(root, &failure)); - return Ok(()); + // 入队:到这里这一条已经过了全部检查,剩下的就是排队等放行。条目只带走它自己的事实 + // (用户条目、创建类型、入队时刻),canonical 形状与 prompt 放行时从它重投影——放行没有失败出口。 + let pending = PendingTurn::new(turn_id, user_item, creation_type, direct_tool_call_now_ms()); + match enqueue_pending_turn(&thread_id, pending) { + Ok(_) => {} + Err(EnqueueRejection::QueueFull) => { + return Err(DirectTurnError::QueueFull { + limit: MAX_PENDING_TURNS, + }) + } } - // 用户条目落盘成功即下发:这一轮从"接单"到"起 codex"之间的一切失败(连不上 - // app-server、执行器未通过验收、历史注入失败)都靠它把失败说明挂回自己那一轮;晚到 - // `turn/start` 之后才发,这些失败就没有用户条目可挂,界面会把说明显示在用户消息上面。 - crate::agent::codex_app_server::emit_direct_thread_user_item(root, &canonical_user_item); - let capture = crate::analytics::gui::capture_writer_context(); - let root = root.to_path_buf(); - tauri::async_runtime::spawn(async move { - run_accepted_direct_turn( - root, - turn_id, - user_prompt, - creation_type, - canonical_user_item, - capture, - analytics_attempt_id, - active_invocation, - reservation, - ) - .await; - }); + // 入队之后立刻踢一脚:队列空且没有回合在跑时,放行就是这一脚,用户点发送不必再等一个调度周期。 + kick_queue_dispatch(root); Ok(()) } -/// 接单之后的整轮:命令不再 await 它,它的收场只走事件流。 -/// -/// 三条收场路径都在这里收口:正常(深层的终态出口写 `turn.completed`)、失败(没有深层终态的 -/// 早退由这里的占用对象补)、任务被丢弃 / panic(占用对象的 `Drop` 补 `host-dropped`)。 -/// -/// 两个守卫都**必须活到整轮结束**,所以随任务搬进来,不留在命令里: -/// `_active_invocation` 是这一轮的调用身份(并发拒单与首页在途回合都读它),`reservation` -/// 是逻辑回合的占用。 -#[allow(clippy::too_many_arguments)] -async fn run_accepted_direct_turn( - root: std::path::PathBuf, - turn_id: String, - user_prompt: String, - creation_type: Option, - canonical_user_item: serde_json::Value, - capture: Option<( - crate::analytics::contract::Context, - crate::analytics::store::AnalyticsWriter, - )>, - analytics_attempt_id: Option, - _active_invocation: DirectTaonierActiveInvocationGuard, - reservation: DirectTurnReservation, -) { - let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id); - let outcome = run_direct_game_creator_turn_at_with_creation_type_and_emitter( - &root, - &user_prompt, - creation_type.as_deref(), - Some(&emitter), - Some(canonical_user_item), - capture, - analytics_attempt_id.as_deref(), - ) - .await; - match outcome { - Ok(reply) => { - // 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。 - emitter.emit("completed", Some("none"), Some(reply), None); - } - Err(error) => { - // 接单之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。 - // 深层已经写出终态时它不覆盖(同一轮只允许一条终态)。 - reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &error)); - } - } -} - #[cfg(test)] mod tests { use super::*; - use crate::agent::{consume_direct_thread, subscribe_direct_thread, DirectThreadEvent}; + use crate::agent::{consume_thread, subscribe_thread, ThreadEvent}; - /// 接单之后的早退也必须有终态。 + /// 放行之后整轮是异步的:测试只能等事件。返回从订阅之后看到的全部事件。 + async fn wait_for_completion(subscription_id: &str) -> Vec { + let mut events = Vec::new(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + events.extend( + consume_thread(subscription_id) + .expect("consume logical turn") + .events, + ); + if events + .iter() + .any(|event| matches!(event, ThreadEvent::TurnCompleted { .. })) + { + return events; + } + assert!( + std::time::Instant::now() < deadline, + "回合没有收口:{events:?}" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + } + + fn user_item(text: &str) -> DirectCodexUserItem { + serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:turn-1:user", + "content": [{ "type": "input_text", "text": text }], + })) + .expect("canonical user item") + } + + /// 放行之后发现的失败也必须收口。 /// - /// 这里用一个"目录存在但不是项目"的根制造一条**接单之后**才发现的失败(连 `run_turn` 的 - /// 收尾都走不到)。命令此时早已返回 `Ok`,前端唯一的收口依据就是事件流,所以占用对象必须 - /// 补出 `turn.completed`——这正是接单化要买的那条不变式。 + /// 命令此刻早就返回了 `Ok`(它只回报入队成立),前端唯一的收口依据就是事件流:占用对象必须补出 + /// `turn.completed`。这里用一个"目录存在但不是项目"的根制造一条放行之后才发现的失败。 #[tokio::test] - async fn a_failure_after_accept_still_closes_the_logical_turn() { + async fn a_failure_after_dispatch_still_closes_the_logical_turn() { let temp = tempfile::tempdir().expect("temp dir"); let root = temp.path().join("not-a-project"); std::fs::create_dir_all(&root).expect("create project dir"); - let thread_id = direct_thread_id_for_project(&root); - let subscription = subscribe_direct_thread(&thread_id); - let _ = consume_direct_thread(&subscription.subscription_id); - let reservation = - DirectTurnReservation::accept(&thread_id, "turn-1", Some("direct-codex:turn-1:user")) - .expect("accept logical turn"); - let invocation = - DirectTaonierActiveInvocationGuard::enter(&root, "turn-1").expect("enter invocation"); + let thread_id = thread_id_for_project(&root); + let subscription = subscribe_thread(&thread_id); + let _ = consume_thread(&subscription.subscription_id); - run_accepted_direct_turn( - root.clone(), - "turn-1".to_string(), - "你好".to_string(), - None, - serde_json::json!({ - "type": "message", - "role": "user", - "id": "direct-codex:turn-1:user", - "content": [{ "type": "input_text", "text": "你好" }], - }), - None, - None, - invocation, - reservation, - ) - .await; + enqueue_direct_codex_turn_typed(&root, user_item("你好"), None, Some("turn-1".to_string())) + .await + .expect("入队成立:命令只回报入队"); - let events = consume_direct_thread(&subscription.subscription_id) - .expect("consume logical turn") - .events; + let events = wait_for_completion(&subscription.subscription_id).await; let terminal = events .iter() .filter_map(|event| match event { - DirectThreadEvent::TurnCompleted { + ThreadEvent::TurnCompleted { status, failure, user_item_id, @@ -262,71 +191,74 @@ mod tests { let failure = failure.as_ref().expect("失败终态必须带载荷"); assert!( !failure.message.trim().is_empty(), - "接单之后的失败必须带上原因" + "放行之后的失败必须带上原因" ); assert_eq!(user_item_id.as_deref(), Some("direct-codex:turn-1:user")); + // 占用已释放:队列继续走得动。 + assert!(!crate::agent::thread_turn_is_active(&thread_id)); } /// 本轮的开口用户条目必须先于整轮里任何可能失败的东西下发。 /// - /// 现场(用户可见的坏体验):命令接单、用户条目落盘之后,整轮在 `turn/start` 之前就失败 - /// (连不上 app-server 一类)。这时如果用户条目还没下发,界面就只剩一条失败说明——它按位置 - /// 落进**上一轮**的分区里,于是"错误显示在用户消息上面"、上一轮顶替本轮显示耗时,本轮的用户 - /// 气泡再自成一个 0.0 秒的假回合。 + /// 现场(用户可见的坏体验):放行、用户条目落盘之后,整轮在 `turn/start` 之前就失败(连不上 + /// app-server 一类)。这时如果用户条目还没下发,界面就只剩一条失败说明——它按位置落进**上一轮** + /// 的分区里,于是"错误显示在用户消息上面"、上一轮顶替本轮显示耗时,本轮的用户气泡再自成一个 + /// 0.0 秒的假回合。 /// - /// 判据取事件流的前两条:命令体是顺序执行的,后台整轮是它之后才起的,所以"开始 → 用户条目" - /// 一定在最前面,之后才可能有失败终态。 + /// 判据取事件流的前两条:放行任务的顺序是"开始 → 下发用户条目 → 跑整轮",所以终态一定在它们 + /// 之后。 #[tokio::test] async fn the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn() { let temp = tempfile::tempdir().expect("temp dir"); let root = temp.path().join("direct-user-item-first"); crate::init_local_game_project_at(&root, "direct-user-item-first", "用户条目先下发") .expect("init project"); - let thread_id = direct_thread_id_for_project(&root); - let subscription = subscribe_direct_thread(&thread_id); - let _ = consume_direct_thread(&subscription.subscription_id); - let user_item: DirectCodexUserItem = serde_json::from_value(serde_json::json!({ - "type": "message", - "role": "user", - "id": "direct-codex:turn-1:user", - "content": [{ "type": "input_text", "text": "hello" }], - })) - .expect("canonical user item"); + let thread_id = thread_id_for_project(&root); + let subscription = subscribe_thread(&thread_id); + let _ = consume_thread(&subscription.subscription_id); - chat_with_game_creator_direct_codex_typed( + enqueue_direct_codex_turn_typed( &root, - user_item, + user_item("hello"), None, Some("turn-1".to_string()), - None, ) .await - .expect("接单成立:命令只回报接单"); + .expect("入队成立:命令只回报入队"); - let events = consume_direct_thread(&subscription.subscription_id) - .expect("consume logical turn") - .events; + let events = wait_for_completion(&subscription.subscription_id).await; + // 队列事件先于放行:`queue.enqueued` → 放行(开始 + 离开队列)→ 开口用户条目 → 终态。 + let started = events + .iter() + .position(|event| matches!(event, ThreadEvent::TurnStarted { .. })) + .expect("这一轮必须被放行"); assert!( matches!( - events.first(), - Some(DirectThreadEvent::TurnStarted { user_item_id, .. }) + events.get(started), + Some(ThreadEvent::TurnStarted { user_item_id, .. }) if user_item_id.as_deref() == Some("direct-codex:turn-1:user") ), - "第一条必须是带身份的回合开始:{events:?}" + "放行的第一条事件必须是带身份的回合开始:{events:?}" ); + // 开口用户条目必须是这一轮里的**第一条条目事件**:它之前只许有回合边界与队列事件。 + let opening = events + .iter() + .position(|event| event.item_id().is_some()) + .expect("本轮的开口用户条目必须下发"); assert!( matches!( - events.get(1), - Some(DirectThreadEvent::ItemCompleted { item, .. }) + events.get(opening), + Some(ThreadEvent::ItemCompleted { item, .. }) if item.item_id() == "direct-codex:turn-1:user" ), - "第二条必须是本轮的开口用户条目:{events:?}" + "第一条条目事件必须是本轮的开口用户条目:{events:?}" ); + assert!(opening > started, "用户条目只能在放行之后:{events:?}"); let terminal = events .iter() - .position(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. })); + .position(|event| matches!(event, ThreadEvent::TurnCompleted { .. })); assert!( - terminal.is_none_or(|index| index > 1), + terminal.is_none_or(|index| index > opening), "终态只能在用户条目之后:{events:?}" ); // 落盘与下发同一份身份:历史里的条目 id 就是事件里的 itemId。 @@ -338,52 +270,43 @@ mod tests { ); } - /// 接单之后的落盘失败:**只走占用对象的失败终态**,命令返回 `Ok`。 + /// 放行之后的落盘失败:**只走占用对象的失败终态**,命令返回 `Ok`。 /// /// 这条路径的 `turn.started` 已经发过,命令再回一个 `Err` 就等于同一个失败下发两次(事件一条 - /// 说明、横幅又一份),而且 `Err` 的含义是**拒单**——前端会把它读成"这一轮没开始"。历史追加写 - /// 有一条测试注入(`.agent/runtime/test-fail-next-direct-project-history-append`),用它把这条 - /// 路径钉成确定性:恰好一条失败终态、命令 `Ok`、占用释放(下一轮还能接单)。 + /// 说明、横幅又一份),而且 `Err` 的含义是**入队失败**——前端会把它读成"这一轮没开始"。历史 + /// 追加写有一条测试注入(`.agent/runtime/test-fail-next-direct-project-history-append`),用它把 + /// 这条路径钉成确定性:恰好一条失败终态、命令 `Ok`、占用释放(下一轮还能继续)。 #[tokio::test] - async fn a_history_write_failure_after_accept_closes_the_turn_instead_of_rejecting() { + async fn a_history_write_failure_after_dispatch_closes_the_turn_instead_of_failing_the_command() + { let temp = tempfile::tempdir().expect("temp dir"); let root = temp.path().join("direct-history-write-failure"); crate::init_local_game_project_at(&root, "direct-history-write", "落盘失败") .expect("init project"); - let thread_id = direct_thread_id_for_project(&root); - let subscription = subscribe_direct_thread(&thread_id); - let _ = consume_direct_thread(&subscription.subscription_id); + let thread_id = thread_id_for_project(&root); + let subscription = subscribe_thread(&thread_id); + let _ = consume_thread(&subscription.subscription_id); // 接下来这次追加写的两次尝试都按"争用失败"返回:确定性地走到落盘失败分支。 std::fs::write( root.join(".agent/runtime/test-fail-next-direct-project-history-append"), "9", ) .expect("write history contention injection"); - let user_item: DirectCodexUserItem = serde_json::from_value(serde_json::json!({ - "type": "message", - "role": "user", - "id": "direct-codex:turn-1:user", - "content": [{ "type": "input_text", "text": "生成一个游戏" }], - })) - .expect("canonical user item"); - chat_with_game_creator_direct_codex_typed( + enqueue_direct_codex_turn_typed( &root, - user_item, + user_item("生成一个游戏"), None, Some("turn-1".to_string()), - None, ) .await - .expect("接单之后的失败不再回到命令返回值:命令只回报接单成立"); + .expect("入队成立:命令只回报入队"); - let events = consume_direct_thread(&subscription.subscription_id) - .expect("consume logical turn") - .events; + let events = wait_for_completion(&subscription.subscription_id).await; let terminals = events .iter() .filter_map(|event| match event { - DirectThreadEvent::TurnCompleted { + ThreadEvent::TurnCompleted { status, failure, .. } => Some((status, failure)), _ => None, @@ -398,20 +321,14 @@ mod tests { "{}", failure.message ); - // 这一轮已经接单,所以走的是**回合失败**:拒单那套 `direct-codex-failure:v2` 收口文案 - // 不许出现在这里(它只属于可留痕的拒单)。 + // 这一轮已经放行,所以走的是**回合失败**:入队失败那套 `direct-codex-failure:v2` 收口文案 + // 不许出现在这里(它只属于可留痕的入队失败)。 assert!( !failure.message.contains("direct-codex-failure"), "{}", failure.message ); - // 占用已释放:下一轮还能接单。 - assert!(!crate::agent::direct_thread_turn_is_active(&thread_id)); - assert!(DirectTurnReservation::accept( - &thread_id, - "turn-2", - Some("direct-codex:turn-2:user") - ) - .is_ok()); + // 占用已释放:下一轮还能继续。 + assert!(!crate::agent::thread_turn_is_active(&thread_id)); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs deleted file mode 100644 index 22adb947d..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ /dev/null @@ -1,1214 +0,0 @@ -//! DirectProject 运行态事件队列。 -//! -//! 这个模块只维护 Thread Manager 的内存事实:每个 thread 一个全局事件序列, -//! 每个 subscriber 一个受保护的消费游标。它不理解前端 reducer,也不负责 JSONL -//! 持久化;调用方必须在完成 item 持久化成功后再追加对应完成事件。 - -use std::collections::{HashMap, HashSet}; -use std::sync::{Mutex, OnceLock}; -use uuid::Uuid; - -use crate::agent::{ - DirectThreadConsumeResult, DirectThreadEvent, DirectThreadSubscriptionBootstrap, -}; - -const DEFAULT_MAX_EVENTS: usize = 8_192; -const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; - -pub(crate) const SUBSCRIPTION_EXPIRED: &str = "SUBSCRIPTION_EXPIRED"; -pub(crate) const DIRECT_THREAD_NOTIFY_EVENT: &str = "game-creator-direct-thread-notify"; - -static DIRECT_THREAD_MANAGER: OnceLock> = OnceLock::new(); -static DIRECT_THREAD_MANAGER_APP_HANDLE: OnceLock = OnceLock::new(); - -/// 队列里的一个事件。`seq` 只服务内部游标,不下发:前端按 `consume` 返回的数组顺序处理。 -#[derive(Clone, Debug)] -struct StoredEvent { - event: DirectThreadEvent, - seq: u64, - bytes: usize, - cleanable: bool, -} - -#[derive(Clone, Debug)] -struct SubscriberState { - cursor: u64, -} - -/// 一条正在跑的逻辑回合的占用:接单时登记,终态写出时解除。 -/// -/// 它同时是首页「运行中的项目」快照的**唯一事实源**([`list_direct_active_turns`]):这一格的 -/// 生命周期就是"这一轮在不在跑",进度字段由运行时那一侧经 [`update_direct_thread_active_turn`] -/// 回填。任务侧不再另建一张活动回合表——同一件事只许有一处真相。 -/// -/// 两个身份别混: -/// - `token` 是这一次接单的占用身份:终态出口只有拿着同一个 token 的占用对象才能写兜底终态, -/// 避免迟到的旧占用把新回合的边界顶掉。它不对外。 -/// - `turn_id` 是给界面看的回合身份(`clientTurnId` 派生),只服务快照与进度回填的匹配。 -#[derive(Clone, Debug)] -struct ActiveDirectTurn { - token: String, - turn_id: String, - project_name: Option, - started_at: u64, - status: String, - activity: Option, - updated_at: u64, - sequence: u64, -} - -/// 首页「运行中的项目」的一条快照。 -/// -/// `project_path` 与线上其它地方的项目身份取同一个字符串:Thread Manager 的线程身份就是项目的 -/// canonical 路径(见 `direct_thread_id_for_project`),所以快照里的项目身份与事件流里的身份 -/// 永远能对上,不需要调用方再做一次归一。 -#[derive(Clone, Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectActiveTurnSnapshot { - pub(crate) project_path: String, - pub(crate) project_name: Option, - pub(crate) turn_id: String, - pub(crate) started_at: u64, - pub(crate) status: String, - pub(crate) activity: Option, - pub(crate) updated_at: u64, - pub(crate) sequence: u64, -} - -#[derive(Clone, Debug)] -struct ThreadState { - next_seq: u64, - events: Vec, - head: usize, - total_bytes: usize, - active_items: HashSet, - unresolved_requests: HashSet, - /// 未收口的逻辑回合。`None` 表示这个 thread 没有正在跑的回合。 - active_turn: Option, - /// 最近一条 `turn.started` / `turn.completed` 的独立拷贝。 - /// - /// TODO(thread-manager): 这里有意只保留"锚点",因为 replay 队列会回收可回收事件, - /// 队列本身不是完美事件日志——被回收的 `turn.started` / `turn.completed` 不会回放, - /// 只有这份拷贝保证新订阅仍能判定"最新回合是否还在跑"。若将来需要回放多个回合的 - /// 生命周期(回合账本、跨进程恢复、按回合统计),必须另建持久 ledger, - /// 不能靠扩大这份拷贝或放宽回收规则来模拟。 - lifecycle_anchor: Option<(u64, DirectThreadEvent)>, - subscribers: HashMap, -} - -impl Default for ThreadState { - fn default() -> Self { - Self { - next_seq: 0, - events: Vec::new(), - head: 0, - total_bytes: 0, - active_items: HashSet::new(), - unresolved_requests: HashSet::new(), - active_turn: None, - lifecycle_anchor: None, - subscribers: HashMap::new(), - } - } -} - -#[derive(Clone, Debug)] -pub(crate) struct DirectThreadManager { - threads: HashMap, - max_events: usize, - max_bytes: usize, -} - -impl Default for DirectThreadManager { - fn default() -> Self { - Self::new() - } -} - -impl DirectThreadManager { - pub(crate) fn new() -> Self { - Self::with_limits(DEFAULT_MAX_EVENTS, DEFAULT_MAX_BYTES) - } - - fn with_limits(max_events: usize, max_bytes: usize) -> Self { - Self { - threads: HashMap::new(), - max_events: max_events.max(1), - max_bytes: max_bytes.max(1), - } - } - - pub(crate) fn append( - &mut self, - thread_id: &str, - event: DirectThreadEvent, - ) -> DirectThreadEvent { - let thread = self.threads.entry(thread_id.to_string()).or_default(); - thread.next_seq = thread.next_seq.saturating_add(1); - let seq = thread.next_seq; - let cleanable = Self::observe_event(thread, seq, &event); - let bytes = serde_json::to_vec(&event) - .map(|value| value.len()) - .unwrap_or_default(); - thread.total_bytes = thread.total_bytes.saturating_add(bytes); - thread.events.push(StoredEvent { - event: event.clone(), - seq, - bytes, - cleanable, - }); - if let Some(item_id) = event.item_id() { - Self::mark_item_events_cleanable(thread, item_id, seq); - } - if let Some(kind) = event.request_kind() { - if kind.is_resolution() { - Self::mark_request_events_cleanable(thread, event.request_id(), seq); - } - } - self.evict(thread_id); - event - } - - pub(crate) fn subscribe(&mut self, thread_id: &str) -> DirectThreadSubscriptionBootstrap { - let thread = self.threads.entry(thread_id.to_string()).or_default(); - let subscription_id = Uuid::new_v4().to_string(); - let cursor = thread.next_seq; - thread - .subscribers - .insert(subscription_id.clone(), SubscriberState { cursor }); - - let mut events = thread - .events - .iter() - .skip(thread.head) - .filter(|stored| Self::is_bootstrap_event(thread, stored)) - .map(|stored| (stored.seq, stored.event.clone())) - .collect::>(); - // 生命周期锚点独立保存:即使队列里那条事件已被回收,也要作为 bootstrap 事件返回。 - if let Some((anchor_seq, anchor)) = thread.lifecycle_anchor.as_ref() { - if !events.iter().any(|(seq, _)| seq == anchor_seq) { - events.push((*anchor_seq, anchor.clone())); - } - } - events.sort_by_key(|(seq, _)| *seq); - DirectThreadSubscriptionBootstrap { - subscription_id, - last_completed_item_id: None, - events: events.into_iter().map(|(_, event)| event).collect(), - } - } - - /// 接单:同一个临界区里拒绝并发、登记占用、追加逻辑回合开始事件。 - /// - /// 返回 `Err(existing_turn_id)` 表示这个 thread 已经有一条没收口的回合——此时不动队列, - /// 由调用方把它投影成接单拒绝。回的是**回合身份**(调用方接单时给的 `turn_id`)而不是占用 - /// `token`:占用 token 只活在这个进程里,界面拿它匹配不了自己发出的那一轮,也没法判断 - /// "撞的是同一轮还是另一轮"。 - fn accept_turn( - &mut self, - thread_id: &str, - token: &str, - turn_id: &str, - user_item_id: Option<&str>, - started_at_ms: u64, - ) -> Result { - { - let thread = self.threads.entry(thread_id.to_string()).or_default(); - if let Some(active) = thread.active_turn.as_ref() { - return Err(active.turn_id.clone()); - } - thread.active_turn = Some(ActiveDirectTurn { - token: token.to_string(), - turn_id: turn_id.to_string(), - project_name: std::path::Path::new(thread_id) - .file_name() - .and_then(|name| name.to_str()) - .map(str::to_string), - started_at: started_at_ms, - // 与"还没有任何进度事件"的状态一致:运行时给出的第一条进度会覆盖它。 - status: "accepted".to_string(), - activity: Some("request-accepted".to_string()), - updated_at: started_at_ms, - sequence: 0, - }); - } - Ok(self.append( - thread_id, - DirectThreadEvent::turn_started(started_at_ms).with_user_item_id(user_item_id), - )) - } - - /// 运行时回填这一轮的进度。只认"仍在跑 + 回合身份一致 + 序号不倒退"的那一次。 - /// - /// 返回快照内容是否真的变了:没有未收口的回合、身份对不上(上一轮迟到的进度)、序号倒退 - /// (乱序到达的旧进度)都必须原地丢弃,不能把快照改成过期的样子;状态与活动都没变的重复 - /// 进度只是推进序号,也不重复通知 GUI。 - fn update_active_turn( - &mut self, - thread_id: &str, - turn_id: &str, - status: &str, - activity: Option<&str>, - sequence: u64, - updated_at: u64, - ) -> bool { - let Some(active) = self - .threads - .get_mut(thread_id) - .and_then(|thread| thread.active_turn.as_mut()) - else { - return false; - }; - if active.turn_id != turn_id || sequence < active.sequence { - return false; - } - let changed = active.status != status || active.activity.as_deref() != activity; - active.status = status.to_string(); - active.activity = activity.map(str::to_string); - active.updated_at = updated_at; - active.sequence = sequence; - changed - } - - /// 首页快照:只导出仍有未收口逻辑回合的 thread。 - fn active_turn_snapshots(&self) -> Vec { - self.threads - .iter() - .filter_map(|(thread_id, thread)| { - let active = thread.active_turn.as_ref()?; - Some(DirectActiveTurnSnapshot { - project_path: thread_id.clone(), - project_name: active.project_name.clone(), - turn_id: active.turn_id.clone(), - started_at: active.started_at, - status: active.status.clone(), - activity: active.activity.clone(), - updated_at: active.updated_at, - sequence: active.sequence, - }) - }) - .collect() - } - - /// 深层的终态出口:解除占用并写下 `turn.completed`。 - /// - /// 不校验 token:这一条由真正跑完这一轮的代码调用,终态就是它算出来的那个(CLI 这类没有 - /// 占用登记的入口也走这里,保持"终态一定下发"的既有语义)。 - fn complete_turn(&mut self, thread_id: &str, event: DirectThreadEvent) -> DirectThreadEvent { - if let Some(thread) = self.threads.get_mut(thread_id) { - thread.active_turn = None; - } - self.append(thread_id, event) - } - - /// 占用对象的兜底出口:只有当这个 thread 仍被同一个 token 占用时才写。 - /// - /// 返回是否真的写了。深层已经写出终态时返回 `false`——兜底不覆盖真实结果。 - fn complete_turn_if_reserved( - &mut self, - thread_id: &str, - token: &str, - event: DirectThreadEvent, - ) -> bool { - let reserved = match self.threads.get_mut(thread_id) { - Some(thread) => match thread.active_turn.as_ref() { - Some(active) if active.token == token => { - thread.active_turn = None; - true - } - _ => false, - }, - None => false, - }; - if !reserved { - return false; - } - self.append(thread_id, event); - true - } - - fn turn_is_active(&self, thread_id: &str) -> bool { - self.threads - .get(thread_id) - .is_some_and(|thread| thread.active_turn.is_some()) - } - - fn subscriber_ids(&self, thread_id: &str) -> Vec { - self.threads - .get(thread_id) - .map(|thread| thread.subscribers.keys().cloned().collect()) - .unwrap_or_default() - } - - pub(crate) fn consume( - &mut self, - subscription_id: &str, - ) -> Result { - let Some((_, thread)) = self - .threads - .iter_mut() - .find(|(_, thread)| thread.subscribers.contains_key(subscription_id)) - else { - return Err(SUBSCRIPTION_EXPIRED.to_string()); - }; - let cursor = thread - .subscribers - .get(subscription_id) - .map(|subscriber| subscriber.cursor) - .expect("subscriber checked above"); - let oldest_seq = thread - .events - .get(thread.head) - .map(|stored| stored.seq) - .unwrap_or(thread.next_seq.saturating_add(1)); - if cursor.saturating_add(1) < oldest_seq { - thread.subscribers.remove(subscription_id); - return Err(SUBSCRIPTION_EXPIRED.to_string()); - } - let mut last_seq = cursor; - let events = thread - .events - .iter() - .skip(thread.head) - .filter(|stored| stored.seq > cursor) - .map(|stored| { - last_seq = stored.seq; - stored.event.clone() - }) - .collect::>(); - thread - .subscribers - .get_mut(subscription_id) - .expect("subscriber remains registered") - .cursor = last_seq; - let result = DirectThreadConsumeResult { events }; - Self::trim_prefix(thread); - Ok(result) - } - - /// 移除订阅者并立即释放它钉住的队列前缀。重复移除是幂等空操作,允许前端 - /// cleanup 与迟到的 bootstrap 回执安全竞争。 - pub(crate) fn unsubscribe(&mut self, subscription_id: &str) -> bool { - let Some((_, thread)) = self - .threads - .iter_mut() - .find(|(_, thread)| thread.subscribers.contains_key(subscription_id)) - else { - return false; - }; - let removed = thread.subscribers.remove(subscription_id).is_some(); - if removed { - Self::trim_prefix(thread); - } - removed - } - - #[cfg(test)] - fn thread_debug(&self, thread_id: &str) -> Option<(usize, usize, usize)> { - self.threads.get(thread_id).map(|thread| { - ( - thread.events.len().saturating_sub(thread.head), - thread.total_bytes, - thread.subscribers.len(), - ) - }) - } - - /// 观察一条事件,返回它本身是否可回收。 - fn observe_event(thread: &mut ThreadState, seq: u64, event: &DirectThreadEvent) -> bool { - match event { - DirectThreadEvent::ItemStarted { item, .. } => { - thread.active_items.insert(item.item_id().to_string()); - false - } - DirectThreadEvent::ItemCompleted { item, .. } => { - thread.active_items.remove(item.item_id()); - true - } - DirectThreadEvent::TurnStarted { .. } | DirectThreadEvent::TurnCompleted { .. } => { - // 队列只保留最新一条生命周期事件,更早的可能已被回收;见 - // `ThreadState::lifecycle_anchor` 的 TODO:这不是完整事件日志。 - thread.lifecycle_anchor = Some((seq, event.clone())); - true - } - DirectThreadEvent::Request { - kind, request_id, .. - } => { - if kind.is_request() { - if let Some(request_id) = request_id.as_deref() { - thread.unresolved_requests.insert(request_id.to_string()); - } - // 没有 request id 的请求事件无法配对,直接视为可回收。 - return request_id.is_none(); - } - if let Some(request_id) = request_id.as_deref() { - thread.unresolved_requests.remove(request_id); - } - true - } - DirectThreadEvent::ItemDelta { .. } => true, - } - } - - /// 新订阅此刻需要补的事件:未完成 item 的完整事件、未解决请求、不可回收的事件。 - fn is_bootstrap_event(thread: &ThreadState, stored: &StoredEvent) -> bool { - if let Some(item_id) = stored.event.item_id() { - return thread.active_items.contains(item_id); - } - if let Some(request_id) = stored.event.request_id() { - return thread.unresolved_requests.contains(request_id); - } - // 生命周期锚点单独补,增量正文这类瞬时事件不回放。 - !stored.cleanable - } - - fn mark_item_events_cleanable(thread: &mut ThreadState, item_id: &str, seq: u64) { - if thread.active_items.contains(item_id) { - return; - } - for stored in &mut thread.events { - if stored.seq <= seq && stored.event.item_id() == Some(item_id) { - stored.cleanable = true; - } - } - } - - fn mark_request_events_cleanable(thread: &mut ThreadState, request_id: Option<&str>, seq: u64) { - let Some(resolved_request_id) = request_id else { - return; - }; - for stored in &mut thread.events { - if stored.seq <= seq - && stored - .event - .request_kind() - .is_some_and(|kind| kind.is_request()) - && stored.event.request_id() == Some(resolved_request_id) - { - stored.cleanable = true; - } - } - } - - fn trim_prefix(thread: &mut ThreadState) { - let min_cursor = thread - .subscribers - .values() - .map(|subscriber| subscriber.cursor) - .min() - .unwrap_or(thread.next_seq); - loop { - let can_pop = thread - .events - .get(thread.head) - .is_some_and(|stored| stored.seq <= min_cursor && stored.cleanable); - if !can_pop { - break; - } - let bytes = thread - .events - .get(thread.head) - .map(|stored| stored.bytes) - .unwrap_or_default(); - thread.total_bytes = thread.total_bytes.saturating_sub(bytes); - thread.head = thread.head.saturating_add(1); - } - Self::compact(thread); - } - - fn compact(thread: &mut ThreadState) { - if thread.head >= 1024 && thread.head.saturating_mul(2) >= thread.events.len() { - thread.events.drain(..thread.head); - thread.head = 0; - } - } - - fn evict(&mut self, thread_id: &str) { - loop { - let over_limit = self.threads.get(thread_id).is_some_and(|thread| { - thread.events.len().saturating_sub(thread.head) > self.max_events - || thread.total_bytes > self.max_bytes - }); - if !over_limit { - if let Some(thread) = self.threads.get_mut(thread_id) { - Self::trim_prefix(thread); - } - return; - } - let Some(thread) = self.threads.get_mut(thread_id) else { - return; - }; - let oldest_seq = thread - .events - .get(thread.head) - .map(|stored| stored.seq) - .unwrap_or(thread.next_seq.saturating_add(1)); - let slowest = thread - .subscribers - .iter() - .filter(|(_, subscriber)| subscriber.cursor < oldest_seq) - .min_by_key(|(_, subscriber)| subscriber.cursor) - .map(|(id, _)| id.clone()); - if let Some(subscription_id) = slowest { - thread.subscribers.remove(&subscription_id); - Self::trim_prefix(thread); - continue; - } - // 未完成 item 的事件可能暂时 pin 住队头;不能为了满足上限截断它。 - return; - } - } -} - -fn global_direct_thread_manager() -> &'static Mutex { - DIRECT_THREAD_MANAGER.get_or_init(|| Mutex::new(DirectThreadManager::new())) -} - -pub(crate) fn set_direct_thread_manager_app_handle(app: tauri::AppHandle) { - let _ = DIRECT_THREAD_MANAGER_APP_HANDLE.set(app); -} - -pub(crate) fn append_direct_thread_event( - thread_id: &str, - event: DirectThreadEvent, -) -> DirectThreadEvent { - let event = { - global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .append(thread_id, event) - }; - notify_direct_thread_subscribers(thread_id); - event -} - -/// 接单:拒绝并发 + 登记占用 + 发逻辑回合开始事件(见 [`DirectThreadManager::accept_turn`])。 -/// `Err` 是这一轮**已有的回合身份**(`turn_id`,也就是调用方的 `clientTurnId`),不是占用 token: -/// 调用方拿它投影成 `TurnAlreadyRunning` 的两个身份字段,界面按"撞的是同一轮还是另一轮"决定要 -/// 不要动当前回合。 -pub(crate) fn accept_direct_thread_turn( - thread_id: &str, - token: &str, - turn_id: &str, - user_item_id: Option<&str>, - started_at_ms: u64, -) -> Result<(), String> { - { - global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .accept_turn(thread_id, token, turn_id, user_item_id, started_at_ms)?; - } - notify_direct_thread_subscribers(thread_id); - crate::agent::emit_direct_active_turns_changed(); - Ok(()) -} - -/// 运行时回填某一轮逻辑回合的进度(状态 / 活动 / 序号)。返回快照内容是否真的变了。 -pub(crate) fn update_direct_thread_active_turn( - thread_id: &str, - turn_id: &str, - status: &str, - activity: Option<&str>, - sequence: u64, - updated_at: u64, -) -> bool { - let changed = global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .update_active_turn(thread_id, turn_id, status, activity, sequence, updated_at); - if changed { - crate::agent::emit_direct_active_turns_changed(); - } - changed -} - -/// 首页「运行中的项目」快照:逻辑回合的唯一导出口(见 [`DirectActiveTurnSnapshot`])。 -pub(crate) fn list_direct_active_turns() -> Result, String> { - let mut turns = global_direct_thread_manager() - .lock() - .map_err(|_| "Direct 线程管理器已损坏".to_string())? - .active_turn_snapshots(); - turns.sort_by(|left, right| left.project_path.cmp(&right.project_path)); - Ok(turns) -} - -/// 深层终态出口:解除占用并写 `turn.completed`。 -pub(crate) fn complete_direct_thread_turn(thread_id: &str, event: DirectThreadEvent) { - { - global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .complete_turn(thread_id, event); - } - notify_direct_thread_subscribers(thread_id); - crate::agent::emit_direct_active_turns_changed(); -} - -/// 占用对象的兜底出口:仍被同一 token 占用时才写,返回是否写了。 -pub(crate) fn complete_direct_thread_turn_if_reserved( - thread_id: &str, - token: &str, - event: DirectThreadEvent, -) -> bool { - let written = { - global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .complete_turn_if_reserved(thread_id, token, event) - }; - if written { - notify_direct_thread_subscribers(thread_id); - crate::agent::emit_direct_active_turns_changed(); - } - written -} - -/// 这个 thread 是否还有没收口的逻辑回合。 -pub(crate) fn direct_thread_turn_is_active(thread_id: &str) -> bool { - global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .turn_is_active(thread_id) -} - -fn notify_direct_thread_subscribers(thread_id: &str) { - let subscriber_ids = { - let manager = global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - manager.subscriber_ids(thread_id) - }; - if let Some(app) = DIRECT_THREAD_MANAGER_APP_HANDLE.get() { - for subscription_id in subscriber_ids { - let _ = tauri::Emitter::emit( - app, - DIRECT_THREAD_NOTIFY_EVENT, - serde_json::json!({ "subscriptionId": subscription_id }), - ); - } - } -} - -pub(crate) fn subscribe_direct_thread(thread_id: &str) -> DirectThreadSubscriptionBootstrap { - global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .subscribe(thread_id) -} - -pub(crate) fn consume_direct_thread( - subscription_id: &str, -) -> Result { - global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .consume(subscription_id) -} - -pub(crate) fn unsubscribe_direct_thread(subscription_id: &str) { - let mut manager = global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - manager.unsubscribe(subscription_id); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::agent::{DirectThreadDeltaKind, DirectThreadItem, DirectThreadRequestKind}; - - fn message(item_id: &str) -> DirectThreadItem { - DirectThreadItem::Message { - item_id: item_id.to_string(), - role: "assistant".to_string(), - text: "内容".to_string(), - at: 0, - } - } - - /// 事件级阶段时间只在重放稳定性用例里逐个指定;其余用例用一个固定值即可, - /// 它们断言的是队列 / 游标语义,不是时间本身。 - const FIXED_AT_MS: u64 = 1_000; - - fn item_started(item_id: &str) -> DirectThreadEvent { - DirectThreadEvent::item_started(message(item_id), FIXED_AT_MS) - } - - fn item_completed(item_id: &str) -> DirectThreadEvent { - DirectThreadEvent::item_completed(message(item_id), FIXED_AT_MS) - } - - fn item_delta(item_id: &str) -> DirectThreadEvent { - DirectThreadEvent::item_delta( - item_id.to_string(), - DirectThreadDeltaKind::Message, - "增量".to_string(), - ) - } - - fn request(kind: DirectThreadRequestKind, request_id: Option<&str>) -> DirectThreadEvent { - DirectThreadEvent::request(kind, request_id.map(str::to_string)) - } - - #[test] - fn subscribers_have_independent_cursors_on_one_global_queue() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS)); - let first = manager.subscribe("thread-1"); - let second = manager.subscribe("thread-1"); - manager.append("thread-1", item_started("item-1")); - manager.append("thread-1", item_delta("item-1")); - - let first_batch = manager - .consume(&first.subscription_id) - .expect("first consume"); - assert_eq!(first_batch.events.len(), 2); - let second_batch = manager - .consume(&second.subscription_id) - .expect("second consume"); - assert_eq!(second_batch.events, first_batch.events); - assert!(manager - .consume(&first.subscription_id) - .expect("empty consume") - .events - .is_empty()); - } - - #[test] - fn unsubscribe_releases_the_subscriber_and_is_idempotent() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", item_started("item-1")); - let subscription = manager.subscribe("thread-1"); - manager.append("thread-1", item_delta("item-1")); - - assert!(manager.unsubscribe(&subscription.subscription_id)); - assert!(!manager.unsubscribe(&subscription.subscription_id)); - assert_eq!( - manager.consume(&subscription.subscription_id), - Err(SUBSCRIPTION_EXPIRED.to_string()) - ); - } - - #[test] - fn unsubscribe_allows_a_cleanable_prefix_to_be_trimmed() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", item_started("item-1")); - let subscription = manager.subscribe("thread-1"); - manager.append("thread-1", item_completed("item-1")); - assert_eq!(manager.thread_debug("thread-1").unwrap().0, 1); - - manager.unsubscribe(&subscription.subscription_id); - - assert_eq!(manager.thread_debug("thread-1").unwrap().0, 0); - } - - #[test] - fn bootstrap_contains_lifecycle_anchor_and_unfinished_events_only() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS)); - manager.append("thread-1", item_started("item-1")); - manager.append("thread-1", item_delta("item-1")); - manager.append("thread-1", item_completed("item-1")); - manager.append("thread-1", item_started("item-2")); - - let bootstrap = manager.subscribe("thread-1"); - assert!(matches!( - bootstrap.events.as_slice(), - [ - DirectThreadEvent::TurnStarted { .. }, - DirectThreadEvent::ItemStarted { item, .. }, - ] if item.item_id() == "item-2" - )); - } - - #[test] - fn completion_releases_item_events_only_after_the_completion_event_is_appended() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", item_started("item-1")); - manager.append("thread-1", item_delta("item-1")); - let bootstrap = manager.subscribe("thread-1"); - manager.append("thread-1", item_completed("item-1")); - let events = manager - .consume(&bootstrap.subscription_id) - .expect("consume completion") - .events; - assert!(matches!( - events.as_slice(), - [DirectThreadEvent::ItemCompleted { item, .. }] if item.item_id() == "item-1" - )); - } - - #[test] - fn slow_subscriber_is_expired_when_queue_limit_is_reached() { - let mut manager = DirectThreadManager::with_limits(2, 100_000); - let subscription = manager.subscribe("thread-1"); - for _ in 0..3 { - manager.append( - "thread-1", - request(DirectThreadRequestKind::RequestResolved, None), - ); - } - assert_eq!( - manager.consume(&subscription.subscription_id), - Err(SUBSCRIPTION_EXPIRED.to_string()) - ); - } - - #[test] - fn current_subscriber_is_not_expired_by_pinned_queue_head() { - let mut manager = DirectThreadManager::with_limits(2, 100_000); - manager.append("thread-1", item_started("item-1")); - let subscription = manager.subscribe("thread-1"); - for _ in 0..3 { - manager.append("thread-1", item_delta("item-1")); - } - assert_ne!( - manager.consume(&subscription.subscription_id), - Err(SUBSCRIPTION_EXPIRED.to_string()) - ); - } - - #[test] - fn unresolved_approval_is_kept_in_bootstrap_until_resolved() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append( - "thread-1", - request( - DirectThreadRequestKind::ApprovalRequested, - Some("request-1"), - ), - ); - let bootstrap = manager.subscribe("thread-1"); - assert_eq!(bootstrap.events.len(), 1); - manager.append( - "thread-1", - request(DirectThreadRequestKind::RequestResolved, Some("request-1")), - ); - assert_eq!( - manager - .consume(&bootstrap.subscription_id) - .expect("consume resolution") - .events - .len(), - 1 - ); - } - - #[test] - fn resolved_request_releases_the_original_requested_event() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append( - "thread-1", - request( - DirectThreadRequestKind::ApprovalRequested, - Some("request-1"), - ), - ); - let subscription = manager.subscribe("thread-1"); - manager.append( - "thread-1", - request(DirectThreadRequestKind::RequestResolved, Some("request-1")), - ); - manager - .consume(&subscription.subscription_id) - .expect("consume resolution"); - assert_eq!(manager.thread_debug("thread-1").unwrap().0, 0); - } - - #[test] - fn turn_completed_anchor_survives_empty_queue_for_new_subscriber() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append( - "thread-1", - DirectThreadEvent::turn_completed("completed".to_string(), FIXED_AT_MS), - ); - let bootstrap = manager.subscribe("thread-1"); - assert!(matches!( - bootstrap.events.as_slice(), - [DirectThreadEvent::TurnCompleted { status, at, .. }] - if status == "completed" && *at == Some(FIXED_AT_MS) - )); - } - - /// 失败终态与正常终态同权:`turn.completed(status="failed")` 必须顶替更早的 `turn.started` - /// 成为锚点,否则队列被回收后新订阅只会看到 `turn.started`,把这轮已收口的回合重放成"还在跑"。 - #[test] - fn failed_turn_completed_replaces_started_anchor() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", DirectThreadEvent::turn_started(1_000)); - manager.append( - "thread-1", - DirectThreadEvent::turn_completed_failed( - crate::agent::DirectTurnFailure::new( - crate::agent::DirectTurnFailureKind::HostDropped, - "回合宿主任务提前结束", - ), - FIXED_AT_MS, - ), - ); - - let bootstrap = manager.subscribe("thread-1"); - assert!(matches!( - bootstrap.events.as_slice(), - [DirectThreadEvent::TurnCompleted { status, failure, at, .. }] - if status == "failed" - && failure.as_ref().is_some_and(|failure| failure.kind - == crate::agent::DirectTurnFailureKind::HostDropped) - && *at == Some(FIXED_AT_MS) - )); - } - - /// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**, - /// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。 - #[test] - fn replayed_events_keep_their_original_stage_time() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", DirectThreadEvent::turn_started(1_000)); - manager.append( - "thread-1", - DirectThreadEvent::item_started(message("item-1"), 2_000), - ); - - let first = manager.subscribe("thread-1"); - assert_eq!( - first - .events - .iter() - .map(DirectThreadEvent::at) - .collect::>(), - vec![Some(1_000), Some(2_000)] - ); - - // 第二个订阅看到的是同一份事件,时间不因"又取了一次当前时间"而漂移。 - let second = manager.subscribe("thread-1"); - assert_eq!(second.events, first.events); - - manager.append( - "thread-1", - DirectThreadEvent::item_completed(message("item-1"), 3_000), - ); - let completion = manager - .consume(&first.subscription_id) - .expect("consume completion") - .events; - assert_eq!( - completion - .iter() - .map(DirectThreadEvent::at) - .collect::>(), - vec![Some(3_000)] - ); - // 重复消费不产生新事件,也不改写已下发过的时间。 - assert!(manager - .consume(&first.subscription_id) - .expect("empty consume") - .events - .is_empty()); - assert_eq!( - completion - .iter() - .map(DirectThreadEvent::at) - .collect::>(), - vec![Some(3_000)] - ); - } - - /// 生命周期锚点重放时必须带上开口用户条目身份:前端在「只有锚点 + 历史切片、运行态一直空」 - /// 的回合里也要能把边界认领给同一条用户条目,而不是按时间戳猜。 - #[test] - fn bootstrap_replays_opener_user_item_id() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append( - "thread-1", - DirectThreadEvent::turn_started(1_000) - .with_user_item_id(Some("direct-codex:turn-1:user")), - ); - let bootstrap = manager.subscribe("thread-1"); - assert_eq!( - bootstrap - .events - .iter() - .map(DirectThreadEvent::user_item_id) - .collect::>(), - vec![Some("direct-codex:turn-1:user")] - ); - assert_eq!(bootstrap.events[0].at(), Some(1_000)); - - // 锚点是独立保存的副本:队列里那条事件被回收之后,新订阅仍拿到同一个身份。 - manager - .consume(&bootstrap.subscription_id) - .expect("consume anchor"); - manager.append( - "thread-1", - DirectThreadEvent::item_completed(message("item-1"), 2_000), - ); - manager.append( - "thread-1", - DirectThreadEvent::turn_completed("completed".to_string(), 3_000) - .with_user_item_id(Some("direct-codex:turn-1:user")), - ); - let second = manager.subscribe("thread-1"); - assert_eq!( - second - .events - .iter() - .map(DirectThreadEvent::user_item_id) - .collect::>(), - vec![Some("direct-codex:turn-1:user")], - "起止同源:终态锚点也带同一个开口用户条目身份" - ); - assert_eq!(second.events[0].at(), Some(3_000)); - } - - #[test] - fn queue_cleanup_only_removes_a_cleanable_prefix() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", item_started("item-1")); - manager.append( - "thread-1", - request(DirectThreadRequestKind::RequestResolved, None), - ); - let subscription = manager.subscribe("thread-1"); - manager - .consume(&subscription.subscription_id) - .expect("consume"); - let (events, _, _) = manager.thread_debug("thread-1").expect("thread"); - assert_eq!( - events, 2, - "unfinished item at queue head blocks middle cleanup" - ); - } - - /// 首页快照就是逻辑回合的导出:接单即出现、进度按序号回填、收口即消失。 - fn snapshot_of( - manager: &DirectThreadManager, - thread_id: &str, - ) -> Option { - manager - .active_turn_snapshots() - .into_iter() - .find(|turn| turn.project_path == thread_id) - } - - #[test] - fn active_turn_snapshot_follows_the_logical_turn_lifecycle() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - let thread_id = "/tmp/快照项目"; - assert!(snapshot_of(&manager, thread_id).is_none()); - - manager - .accept_turn(thread_id, "token-1", "turn-1", Some("u-1"), FIXED_AT_MS) - .expect("accept"); - let accepted = snapshot_of(&manager, thread_id).expect("accepted turn is visible"); - assert_eq!(accepted.turn_id, "turn-1"); - assert_eq!(accepted.project_name.as_deref(), Some("快照项目")); - assert_eq!(accepted.started_at, FIXED_AT_MS); - assert_eq!(accepted.status, "accepted"); - assert_eq!(accepted.activity.as_deref(), Some("request-accepted")); - assert_eq!(accepted.sequence, 0); - - assert!(manager.update_active_turn( - thread_id, - "turn-1", - "streaming", - Some("file-write"), - 3, - 42, - )); - let running = snapshot_of(&manager, thread_id).expect("running turn is visible"); - assert_eq!(running.status, "streaming"); - assert_eq!(running.activity.as_deref(), Some("file-write")); - assert_eq!(running.sequence, 3); - assert_eq!(running.updated_at, 42); - - // 序号倒退与身份对不上的进度都不许改快照。 - assert!(!manager.update_active_turn(thread_id, "turn-1", "failed", None, 2, 99)); - assert!(!manager.update_active_turn(thread_id, "turn-2", "failed", None, 4, 99)); - assert_eq!( - snapshot_of(&manager, thread_id) - .expect("snapshot unchanged") - .status, - "streaming" - ); - - manager.complete_turn( - thread_id, - DirectThreadEvent::turn_completed("completed".to_string(), 5_000), - ); - assert!(snapshot_of(&manager, thread_id).is_none()); - } - - /// 并发接单回给调用方的是**回合身份**(`turn_id`),不是占用 `token`:token 只活在这个进程 - /// 里,界面拿它匹配不了自己发出的那一轮。 - #[test] - fn accept_conflict_returns_the_existing_turn_id() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager - .accept_turn("thread-1", "token-1", "turn-1", None, FIXED_AT_MS) - .expect("accept"); - - let conflict = manager - .accept_turn("thread-1", "token-2", "turn-2", None, FIXED_AT_MS) - .err(); - - assert_eq!(conflict.as_deref(), Some("turn-1")); - } - - /// 首页快照的变更通知必须跟着快照自己的事实源走:接单、进度内容变化、收口各通知一次, - /// 状态与活动都没变的重复进度不重复通知。 - /// - /// 计数器是进程级的,因此本用例与仓库既有的 Rust 定向命令一样按 `--test-threads=1` 跑。 - #[test] - fn active_turn_changes_publish_one_notification_per_real_change() { - let thread_id = format!("/tmp/事件项目-{}", Uuid::new_v4()); - let before = crate::agent::direct_active_turns_event_test_count(); - - accept_direct_thread_turn(&thread_id, "token-1", "turn-1", Some("u-1"), FIXED_AT_MS) - .expect("accept"); - assert_eq!( - crate::agent::direct_active_turns_event_test_count(), - before + 1, - "接单必须通知一次" - ); - - assert!(update_direct_thread_active_turn( - &thread_id, - "turn-1", - "streaming", - Some("file-write"), - 3, - 42, - )); - let after_progress = crate::agent::direct_active_turns_event_test_count(); - assert_eq!(after_progress, before + 2, "进度内容变化必须通知一次"); - - assert!(!update_direct_thread_active_turn( - &thread_id, - "turn-1", - "streaming", - Some("file-write"), - 4, - 43, - )); - assert_eq!( - crate::agent::direct_active_turns_event_test_count(), - after_progress, - "状态与活动都没变的重复进度不重复通知" - ); - - complete_direct_thread_turn( - &thread_id, - DirectThreadEvent::turn_completed("completed".to_string(), 5_000), - ); - assert_eq!( - crate::agent::direct_active_turns_event_test_count(), - after_progress + 1, - "收口必须通知一次" - ); - assert!(list_direct_active_turns() - .expect("list active turns") - .into_iter() - .all(|turn| turn.project_path != thread_id)); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 00fd15be0..1300be1b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -267,7 +267,7 @@ impl DirectToolBridge { impl DirectToolBridgeState { fn begin_user_turn(self: &Arc) -> Result { - let turn_id = direct_taonier_active_invocation_id_at(&self.root)?; + let turn_id = active_turn_id_at(&self.root)?; let mut authorization = self .turn_authorization .lock() @@ -4202,6 +4202,14 @@ mod tests { let temporary = tempfile::tempdir().unwrap(); let root = temporary.path().join("project"); let config = temporary.path().join("config"); + // 成绩由宿主在终态直写:这一轮跑在哪个身份下,就看放行代次与终态代次是否同代。 + let _platform_session = crate::platform_session::install_test_platform_session( + "A", + "token-a", + "https://dev.genarrative.world", + ); + let release_identity_generation = + crate::platform_session::current_platform_session_write_state().identity_generation; let (metadata, context, writer) = analytics_test_writer(&config); let original_capture = Some((metadata.context.clone(), writer.clone())); let mut lifecycle = crate::analytics::gui::LifecycleFixture::start( @@ -4264,13 +4272,12 @@ mod tests { ); assert_eq!(session.analytics_output_revision(), Some(revision.clone())); lease.finish(true, true, None).unwrap(); - let attempt = uuid::Uuid::new_v4().to_string(); run::direct_finished( Some((context.clone(), writer.clone())), &root, &project_id, &metadata, - Some(&attempt), + release_identity_generation, run::Outcome { turn_id: Some("analytics-write".into()), end_reason: RunEndReason::Failed, @@ -4280,7 +4287,6 @@ mod tests { revision_id: session.analytics_output_revision(), }, ); - run::settle(Some((context.clone(), writer.clone())), &attempt, false); // 默认项目使用 npm:预览服务读取真实构建目录,夹具提供构建入口,不调用构建器。 let served_root = crate::project_game_root(&root); fs::create_dir_all(&served_root).unwrap(); @@ -4840,8 +4846,7 @@ mod tests { assert!(state.begin_user_turn().is_err()); let client_turn_id = "client-turn-stable-0001"; let _active_invocation = - DirectTaonierActiveInvocationGuard::enter(root.path(), client_turn_id) - .expect("client-owned stable invocation"); + TurnReservation::accept_for_test(&thread_id_for_project(root.path()), client_turn_id); let active_turn = state .begin_user_turn() .expect("client turn authorization state"); 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 index 718ed37a8..9d83b435d 100644 --- 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 @@ -8,7 +8,7 @@ //! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的 //! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 -use super::direct_thread_wire::sanitize_detail_text; +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}; 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 56fb8c721..5deb4efc2 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 @@ -3845,19 +3845,19 @@ mod tests { (handle, receiver) } - /// 真实项目 + 真实工具桥 + 真实 Direct 回合身份。 + /// 真实项目 + 真实工具桥 + 真实 Direct 回合占用(这一轮的调用身份)。 async fn tool_chain_start( root: &Path, ) -> ( super::super::direct_tool_bridge::DirectToolBridge, - DirectTaonierActiveInvocationGuard, + TurnReservation, super::super::direct_tool_bridge::DirectExecutionTestFixture, ) { let bridge = super::super::direct_tool_bridge::start_direct_tool_bridge(root, false) .await .expect("start tool chain bridge"); - let turn = DirectTaonierActiveInvocationGuard::enter(root, "tool-chain-turn") - .expect("arm tool chain direct turn"); + let turn = + TurnReservation::accept_for_test(&thread_id_for_project(root), "tool-chain-turn"); let execution = super::super::direct_tool_bridge::direct_execution_fixture(root, "tool-chain-turn") .await; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs deleted file mode 100644 index fce538000..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! DirectProject 的接单:把"一条用户消息被接单"变成 Thread Manager 里一对必然成对的逻辑回合事件。 -//! -//! 这个模块只有一件事,别再往里加第二件:**接单成立的那一刻**在同一个临界区里拒绝并发、登记占用、 -//! 发出逻辑回合开始事件;占用对象持有这一轮的终态出口——正常 / 失败 / 接单后的前置失败谁先写谁算, -//! 都没写时由 `Drop` 补一条 `host-dropped`。 -//! -//! 为什么回合边界不能继续镜像 Codex 原生回合:`turn/start` 之前的失败(连不上 app-server、配置未 -//! 就绪失败、历史注入失败)根本没有原生回合可以镜像,而它们同样是"这一轮已经成立"。设计见 -//! `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 - -use uuid::Uuid; - -use super::{ - accept_direct_thread_turn, complete_direct_thread_turn_if_reserved, direct_tool_call_now_ms, - DirectTurnError, DirectTurnTerminal, -}; - -/// 一次接单的占用。持有它就代表这一轮还没收口。 -/// -/// 生命周期由调用方决定:命令把整轮任务 spawn 出去时把它一起搬进任务,任务结束(正常或失败) -/// 时它随任务一起 drop。**持有顺序要与单飞锁一致**:单飞锁先声明、占用后声明,drop 时占用先收尾, -/// 新回合不可能插到中间。 -pub(crate) struct DirectTurnReservation { - thread_id: String, - token: String, - user_item_id: Option, -} - -impl DirectTurnReservation { - /// 接单:登记占用并发出逻辑回合开始事件。 - /// - /// 失败表示这个 thread 已经有一条没收口的回合(并发接单),此时不改队列、不发事件。 - /// `client_turn_id` 是给界面看的回合身份(首页快照与进度回填按它匹配),与占用身份 `token` - /// 是两件事:前者来自调用方,后者只活在这个进程里。 - /// - /// 拒单载荷里的两个身份都取**回合身份**:`existing` 是已在跑的那一轮的 `clientTurnId` - /// (Thread Manager 回的就是它),`incoming` 是本次请求的 `clientTurnId`。同一轮重发时两者 - /// 相等,界面才走得到"同一轮消息仍在处理中"那条文案。 - pub(crate) fn accept( - thread_id: &str, - client_turn_id: &str, - user_item_id: Option<&str>, - ) -> Result { - let token = Uuid::new_v4().to_string(); - accept_direct_thread_turn( - thread_id, - &token, - client_turn_id, - user_item_id, - direct_tool_call_now_ms(), - ) - .map_err(|existing| DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: existing, - incoming_invocation_id: client_turn_id.to_string(), - })?; - Ok(Self { - thread_id: thread_id.to_string(), - token, - user_item_id: user_item_id.map(str::to_string), - }) - } - - pub(crate) fn thread_id(&self) -> &str { - &self.thread_id - } - - /// 接单之后还没走到深层终态就失败的收口口:只有这一轮仍被自己占用时才写。 - /// - /// 深层(真正跑完这一轮的代码)已经写出终态时返回 `false`,兜底不覆盖真实结果。 - pub(crate) fn finish_if_unfinished(&self, terminal: DirectTurnTerminal) -> bool { - complete_direct_thread_turn_if_reserved( - &self.thread_id, - &self.token, - terminal.event(direct_tool_call_now_ms(), self.user_item_id.as_deref()), - ) - } -} - -impl Drop for DirectTurnReservation { - fn drop(&mut self) { - // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 - // 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。 - let _ = self.finish_if_unfinished(DirectTurnTerminal::host_dropped()); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::agent::{ - consume_direct_thread, direct_thread_turn_is_active, subscribe_direct_thread, - DirectThreadEvent, DirectTurnFailure, DirectTurnFailureKind, - }; - - /// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。 - fn watch(thread_id: &str) -> String { - let bootstrap = subscribe_direct_thread(thread_id); - let _ = consume_direct_thread(&bootstrap.subscription_id); - bootstrap.subscription_id - } - - fn pending(subscription_id: &str) -> Vec { - consume_direct_thread(subscription_id) - .expect("consume") - .events - } - - fn turn_completed_events(events: &[DirectThreadEvent]) -> Vec<&DirectThreadEvent> { - events - .iter() - .filter(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. })) - .collect() - } - - fn unique_thread(label: &str) -> String { - format!("accept-test-{label}-{}", Uuid::new_v4()) - } - - #[test] - fn accept_emits_a_logical_turn_started_and_holds_the_turn() { - let thread = unique_thread("started"); - let subscription = watch(&thread); - - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - - let events = pending(&subscription); - assert_eq!(events.len(), 1, "{events:?}"); - match &events[0] { - DirectThreadEvent::TurnStarted { user_item_id, .. } => { - assert_eq!(user_item_id.as_deref(), Some("u-1")); - } - other => panic!("expected turn.started, got {other:?}"), - } - assert!(direct_thread_turn_is_active(&thread)); - drop(reservation); - } - - #[test] - fn a_second_accept_is_rejected_while_the_turn_is_open() { - let thread = unique_thread("busy"); - let subscription = watch(&thread); - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - // 先取走第一条接单自己的开始事件,之后的"空"才只说明被拒的这一次没写东西。 - assert_eq!(pending(&subscription).len(), 1); - - let rejected = DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")); - - assert!(matches!( - rejected, - Err(DirectTurnError::TurnAlreadyRunning { .. }) - )); - let events = pending(&subscription); - assert_eq!(events.len(), 0, "被拒的接单不许产生事件:{events:?}"); - drop(reservation); - } - - /// 拒单载荷里的两个身份都是**回合身份**:撞的是同一轮时两者相等,界面才走得到"同一轮消息仍在 - /// 处理中";撞的是另一轮时两者不等,界面才敢提示"另一条回合在运行"。占用 token 只活在本进程, - /// 一旦漏进载荷,这两个分支就都判不出来(UUID 永远不等于界面的 `clientTurnId`)。 - #[test] - fn accept_conflict_reports_client_turn_ids_not_reservation_tokens() { - let thread = unique_thread("same-turn-conflict"); - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - - let same_turn = match DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")) { - Ok(_) => panic!("同一 thread 的第二条回合必须被拒"), - Err(error) => error, - }; - let DirectTurnError::TurnAlreadyRunning { - existing_invocation_id, - incoming_invocation_id, - } = &same_turn - else { - panic!("expected a concurrency rejection, got {same_turn:?}"); - }; - assert_eq!(existing_invocation_id, "turn-1"); - assert_eq!(incoming_invocation_id, "turn-1"); - assert!( - same_turn.to_string().contains("同一轮消息仍在处理中"), - "{same_turn}" - ); - - let other_turn = match DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")) { - Ok(_) => panic!("同一 thread 的第二条回合必须被拒"), - Err(error) => error, - }; - assert!(matches!( - &other_turn, - DirectTurnError::TurnAlreadyRunning { - existing_invocation_id, - incoming_invocation_id, - } if existing_invocation_id == "turn-1" && incoming_invocation_id == "turn-2" - )); - assert!( - other_turn.to_string().contains("另一条 Direct 客户端回合"), - "{other_turn}" - ); - drop(reservation); - } - - #[test] - fn drop_without_a_terminal_writes_a_host_dropped_terminal() { - let thread = unique_thread("drop"); - let subscription = watch(&thread); - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - assert!(reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped())); - assert!(!direct_thread_turn_is_active(&thread)); - - // 显式收口之后 Drop 不再补第二条:兜底只负责"没人写过"的那一种。 - drop(reservation); - - let events = pending(&subscription); - let completed = turn_completed_events(&events); - assert_eq!(completed.len(), 1, "{events:?}"); - match completed[0] { - DirectThreadEvent::TurnCompleted { - user_item_id, - failure: Some(failure), - .. - } => { - assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped); - assert_eq!(user_item_id.as_deref(), Some("u-1")); - } - other => panic!("expected a failed terminal, got {other:?}"), - } - } - - #[test] - fn the_deep_terminal_wins_and_the_fallback_stays_silent() { - let thread = unique_thread("deep"); - let subscription = watch(&thread); - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - - // 深层收口:真正跑完这一轮的代码算出来的终态。 - let deep = DirectThreadEvent::turn_completed_failed( - DirectTurnFailure::new( - DirectTurnFailureKind::Timeout, - "等待模型回执超时".to_string(), - ), - 2_000, - ) - .with_user_item_id(Some("u-1")); - crate::agent::complete_direct_thread_turn(&thread, deep); - - assert!( - !reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped()), - "深层已收口时兜底不许再写" - ); - drop(reservation); - - let events = pending(&subscription); - let completed = turn_completed_events(&events); - assert_eq!(completed.len(), 1, "一轮只许有一条终态:{events:?}"); - match completed[0] { - DirectThreadEvent::TurnCompleted { failure, .. } => { - assert_eq!( - failure.as_ref().map(|f| f.kind), - Some(DirectTurnFailureKind::Timeout) - ); - } - other => panic!("expected a terminal, got {other:?}"), - } - } - - #[test] - fn the_thread_can_be_accepted_again_after_the_turn_is_settled() { - let thread = unique_thread("again"); - let first = DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - drop(first); - - let second = - DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")).expect("second accept"); - - assert!(direct_thread_turn_is_active(&thread)); - drop(second); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs index 21684a950..39cbfbb72 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs @@ -5,16 +5,16 @@ //! 通道断开要带宿主诊断。用不同变体各带各的字段,分流靠 `match`,不靠 `kind` 字段 + 共用字段的 //! 伪结构化,也不靠对错误文本做子串匹配。 //! -//! 走哪条通道由**发生位置**决定,不由错误种类决定(`接单化` 之后的口径): -//! - **接单前**发生的 = 拒单:只出提示 / 横幅,不做失败载荷、不写失败诊断、不上报成 +//! 走哪条通道由**发生位置**决定,不由错误种类决定(`入队化` 之后的口径): +//! - **入队前**发生的 = 入队失败:只出提示 / 横幅,不做失败载荷、不写失败诊断、不上报成 //! "智能创作失败"。命令返回 `Err` 的就是这一类。 -//! - **接单后**发生的 = 回合失败:事件载荷、横幅、应用日志、错误上报池四处一致;命令早已返回 +//! - **放行后**发生的 = 回合失败:事件载荷、横幅、应用日志、错误上报池四处一致;命令早已返回 //! `Ok`,所以一律由宿主侧的占用对象投影成 `turn.completed.failure`。 //! -//! 所以"同一种错误在接单前后走不同通道"是正常的:[`DirectTurnError::EnvironmentNotReady`] 两边 +//! 所以"同一种错误在入队与放行走不同通道"是正常的:[`DirectTurnError::EnvironmentNotReady`] 两边 //! 都可能出现,位置说了算。这里**没有**、也不该有"这个变体是不是回合失败"的判据。 //! -//! 事件载荷(`direct_thread_wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段: +//! 事件载荷(`thread_manager::wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段: //! 那是**线上协议**,由 [`DirectTurnError::wire_kind`] 与 `Display` 在这一个出口投影出来,不是 //! 另一种状态模型。跨进程边界(`#[tauri::command]`)同样只给前端一个字符串:那是**序列化**, //! 由 `Display` 一处生成;Rust 侧任何地方都不再解析这个字符串。 @@ -38,7 +38,7 @@ const DIRECT_CODEX_NATIVE_KIND_PREFIX: &str = "codex-app-server-error:"; /// 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 /// -/// 线上取值跟着拒单 / 失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 +/// 线上取值跟着入队失败 / 回合失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "kebab-case")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] @@ -62,7 +62,7 @@ impl DirectCodexFailureStage { /// 宿主等不到模型回执时,撞的是哪一条上限。 /// -/// 跟着拒单 / 失败载荷一起给前端,界面不靠文案区分这两条。 +/// 跟着入队失败 / 回合失败载荷一起给前端,界面不靠文案区分这两条。 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "kebab-case")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] @@ -227,7 +227,7 @@ pub(crate) enum DirectTurnFailureKind { TransportFailed, /// app-server 或上游明确拒绝了这次请求。 RequestRejected, - /// 接单之后的连接 / 配置 / 凭据 / 脚手架未就绪(不是模型的错,界面语气也不同)。 + /// 放行之后的连接 / 配置 / 凭据 / 脚手架未就绪(不是模型的错,界面语气也不同)。 EnvironmentNotReady, /// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)。 TurnInterrupted, @@ -235,7 +235,7 @@ pub(crate) enum DirectTurnFailureKind { HostDropped, } -/// 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着拒单 / 失败载荷一起给前端。 +/// 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着入队失败 / 回合失败载荷一起给前端。 /// /// 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 /// 完全一致:事件的 `failure.kind` 就是这一份取值,界面按它选语气,不拿它做流程分支。 @@ -385,18 +385,15 @@ impl DirectModelCallKind { )] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectTurnError { - // ───────── 拒单:接单之前发生,这一轮没有开始 ───────── + // ───────── 入队失败:这一轮没有开始,也没有进队列 ───────── /// `clientTurnId` 没给:没有稳定回合身份,拒绝创建可计费身份。 ClientTurnIdMissing, /// `clientTurnId` 形状非法:长度与字符集由宿主定,界面按同一份约束生成。 ClientTurnIdMalformed { min_chars: usize, max_chars: usize }, - /// 同一项目已有另一条回合在跑(或同一 `clientTurnId` 并发复用)。 + /// 待发消息队列已满:这一条没进队,等前面几条发完再发。 /// - /// 两个身份都要带上,因为"撞的是哪一轮"决定界面该不该动当前回合。 - TurnAlreadyRunning { - existing_invocation_id: String, - incoming_invocation_id: String, - }, + /// 上限只落在宿主这一处(`MAX_PENDING_TURNS`),随载荷带出去,界面不自己数一份。 + QueueFull { limit: usize }, /// 项目目录锚不定(符号链接 / 权限 / 目录被删)。 ProjectRootUnanchored { cause: String }, /// 项目目录不存在或不是绝对路径。 @@ -407,13 +404,13 @@ pub(crate) enum DirectTurnError { InputRejected { detail: String }, /// 结构化消息既没有正文也没有任何引用。 ContentEmpty, - /// 环境 / 凭据 / 脚手架未就绪。**接单前后都可能出现**:接单前是拒单(工程 / 凭据还没准备好), - /// 接单后是回合失败(分类 `environment-not-ready`,例如 `turn/start` 之前连不上 app-server)。 + /// 环境 / 凭据 / 脚手架未就绪。**入队与放行都可能出现**:入队时是入队失败(工程 / 凭据还没准备 + /// 好),放行后是回合失败(分类 `environment-not-ready`,例如 `turn/start` 之前连不上 app-server)。 EnvironmentNotReady { detail: String }, /// 宿主执行账本取不到(初始化失败、归属锁被占、状态损坏、时钟回退)。 HostStateUnavailable { detail: String }, - // ───────── 回合失败:接单之后发生,这一轮已经开始 ───────── + // ───────── 回合失败:放行之后发生,这一轮已经开始 ───────── /// 模型调用失败:`kind` 是分类,`detail` 是平台层原文(就是给用户看的那句话)。 ModelCallFailed { kind: DirectModelCallKind, @@ -442,7 +439,7 @@ pub(crate) enum DirectTurnError { TurnFailedUnclassified { detail: String }, } -/// 拒单载荷:命令边界交给前端的**结构化拒绝**。 +/// 入队失败载荷:命令边界交给前端的**结构化入队失败**。 /// /// 为什么不是只给一句话:界面要按变体分流——认得的"前置条件不满足 / 用户参数无效"给一条与用户 /// 消息同级的提示且不上报,认不得的原样抛出交给既有捕获链路。文案只是给人看的最后一步,仍由 @@ -450,14 +447,14 @@ pub(crate) enum DirectTurnError { #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectTurnRejection { +pub(crate) struct DirectTurnEnqueueFailure { /// 结构化变体:界面按 `error.type` 分流,不解析文案。 pub(crate) error: DirectTurnError, /// 可展示文案(`Display` 的唯一出口)。 pub(crate) message: String, } -impl DirectTurnRejection { +impl DirectTurnEnqueueFailure { pub(crate) fn new(error: DirectTurnError) -> Self { Self { message: error.to_string(), @@ -467,12 +464,12 @@ impl DirectTurnRejection { } impl DirectTurnError { - /// 命令边界要不要为这条**拒单**补一份运行错误诊断。 + /// 命令边界要不要为这条**入队失败**补一份运行错误诊断。 /// /// 只有"宿主 / 环境的事实故障、用户自己改不了"才值得进 `.agent/runtime/errors` 与应用日志; - /// 空内容、`clientTurnId` 形状、另一轮在跑、权限策略、目录锚不定 / 不是绝对路径都是用户自己 - /// 就能修的操作结果,留痕只会变成噪声;它们仍按 `Display` 给用户一句可读的话。判据按变体分, - /// 不看文案。 + /// 空内容、`clientTurnId` 形状、队列满、另一轮在跑、权限策略、目录锚不定 / 不是绝对路径都是 + /// 用户自己就能修的操作结果,留痕只会变成噪声;它们仍按 `Display` 给用户一句可读的话。 + /// 判据按变体分,不看文案。 /// /// 回合级失败恒为 `false`:它们在上游(`record_direct_codex_failure`)已经写过诊断,边界再写一次 /// 就是同一件事留两份。 @@ -482,7 +479,7 @@ impl DirectTurnError { Self::EnvironmentNotReady { .. } | Self::HostStateUnavailable { .. } => true, Self::ClientTurnIdMissing | Self::ClientTurnIdMalformed { .. } - | Self::TurnAlreadyRunning { .. } + | Self::QueueFull { .. } // 项目目录锚不定(符号链接 / 权限 / 目录被删)与目录不存在同类:都是用户能自己修好的 // 文件系统事实,诊断文案不该顶替那句"无法锚定 Direct 调用项目目录:{cause}"。 | Self::ProjectRootUnanchored { .. } @@ -644,23 +641,10 @@ impl fmt::Display for DirectTurnError { formatter, "clientTurnId 必须为 {min_chars} 到 {max_chars} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" ), - Self::TurnAlreadyRunning { - existing_invocation_id, - incoming_invocation_id, - } => { - // 两条文案按身份是否相同分岔,但**不再有机器前缀**:界面按 `error.type` 与其 - // 两个身份字段分流,不解析文案(前缀曾经是协议约定,现在只是噪声)。 - if existing_invocation_id == incoming_invocation_id { - formatter.write_str( - "同一轮消息仍在处理中,已拒绝并发复用同一 clientTurnId;请等它结束或点「终止」后再发送", - ) - } else { - write!( - formatter, - "当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份;可在输入盒点「终止」结束它,或等它结束后再发送" - ) - } - } + Self::QueueFull { limit } => write!( + formatter, + "待发消息已达上限(最多 {limit} 条),请等前面几条发完再发送" + ), Self::ProjectRootUnanchored { cause } => { write!(formatter, "无法锚定 Direct 调用项目目录:{cause}") } @@ -1013,11 +997,6 @@ mod tests { policy_detail: "项目权限策略拒绝执行:conversation.write".into(), } .is_reportable()); - assert!(!DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: "turn-1".into(), - incoming_invocation_id: "turn-2".into(), - } - .is_reportable()); // 回合级失败在上游已经写过诊断。 assert!( !DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败") @@ -1025,26 +1004,6 @@ mod tests { ); } - /// 并发拒绝的两条文案按身份是否相同分岔,身份必须原样带出来。 - #[test] - fn concurrent_rejection_keeps_both_invocations_and_splits_the_copy() { - let same = DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: "turn-1".into(), - incoming_invocation_id: "turn-1".into(), - }; - assert!(same.to_string().contains("同一轮消息仍在处理中")); - let different = DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: "turn-1".into(), - incoming_invocation_id: "turn-2".into(), - }; - assert!(different - .to_string() - .contains("另一条 Direct 客户端回合正在运行")); - assert!(!different - .to_string() - .starts_with("direct-codex-turn-already-running:")); - } - /// 边界序列化:字符串只由 Display 生成,且与改造前的可见文本一致。 #[test] fn boundary_serialization_uses_display() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs index 7c78d7215..1d3d004c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs @@ -5,10 +5,10 @@ //! 1. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么; //! 2. [`DirectTurnTerminal::event`]:把终态投影成 `turn.completed` 事件。 //! -//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 `direct_turn_accept.rs` 的接单占用对象里: +//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 `thread_manager::dispatch` 的放行占用对象里: //! 这个模块只负责"什么算失败、原因怎么写"。 //! -//! 失败载荷的**形状**属于线上协议,定义在 `direct_thread_wire.rs`(`DirectTurnFailure`); +//! 失败载荷的**形状**属于线上协议,定义在 `thread_manager::wire`(`DirectTurnFailure`); //! 载荷的 `kind` 与 `message` 由 [`DirectTurnError`] 投影而来(`kind` 的取值表见 //! [`DirectTurnError::wire_kind`]);这里只负责"什么算失败、原因怎么写、什么时候兜底", //! 不碰事件队列的搬运规则,也不自己认 `LlmError`。 @@ -16,8 +16,8 @@ use std::path::Path; use super::{ - redact_agent_runtime_error, DirectThreadEvent, DirectTurnError, DirectTurnFailure, - DirectTurnFailureKind, + redact_agent_runtime_error, DirectTurnError, DirectTurnFailure, DirectTurnFailureKind, + ThreadEvent, }; /// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于 @@ -36,10 +36,10 @@ pub(crate) struct DirectTurnTerminal { impl DirectTurnTerminal { /// 终态事件:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 - pub(crate) fn event(self, completed_at: u64, user_item_id: Option<&str>) -> DirectThreadEvent { + pub(crate) fn event(self, completed_at: u64, user_item_id: Option<&str>) -> ThreadEvent { let event = match self.failure { - Some(failure) => DirectThreadEvent::turn_completed_failed(failure, completed_at), - None => DirectThreadEvent::turn_completed(self.status, completed_at), + Some(failure) => ThreadEvent::turn_completed_failed(failure, completed_at), + None => ThreadEvent::turn_completed(self.status, completed_at), }; event.with_user_item_id(user_item_id) } @@ -122,7 +122,7 @@ impl DirectTurnTerminal { #[cfg(test)] mod tests { use super::*; - use crate::agent::{consume_direct_thread, subscribe_direct_thread}; + use crate::agent::{consume_thread, subscribe_thread}; use platform_llm::LlmError; fn history_root() -> std::path::PathBuf { @@ -245,7 +245,7 @@ stderrClass=nonempty;stderrBytes=1000"; assert!(event.failure().is_none()); assert!(matches!( event, - DirectThreadEvent::TurnCompleted { ref status, .. } if status == "completed" + ThreadEvent::TurnCompleted { ref status, .. } if status == "completed" )); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs index 33b849435..4dc25f47f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs @@ -368,10 +368,10 @@ async fn start_for_current_turn( key: String, build: bool, ) -> Result { - let turn_id = direct_taonier_active_invocation_id_at(root)?; + let turn_id = active_turn_id_at(root)?; let root = root.to_path_buf(); tokio::task::spawn_blocking(move || { - if direct_taonier_active_invocation_id_at(&root)? != turn_id { + if active_turn_id_at(&root)? != turn_id { return Err("validation-turn-changed: 当前验证所属回合已结束".into()); } let session = super::direct_execution::current(&root)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 5aa5afb51..1bffed9d3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -625,7 +625,7 @@ const ERROR_REDACTED_KEY: &str = "[redacted-sensitive-field]"; /// information whenever a safe error line mentions a credential field. /// /// 逐行脱敏,但**保留每个 chunk 末尾的换行**:本函数会被流式增量逐段调用 -/// (`direct_thread_delta_text` → 前端把各段拼成一条消息再交给 Markdown 渲染)。用 +/// (`thread_delta_text` → 前端把各段拼成一条消息再交给 Markdown 渲染)。用 /// `lines()` + `join("\n")` 会把「以换行结尾的段」的末尾换行吃掉,拼接后段落、列表项和表格行 /// 会并进同一行,整条消息的 Markdown 结构(尤其表格)就作废了。 pub(crate) fn sanitize_error_context(value: &str) -> String { 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 1628f6b0b..10dd259f3 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 @@ -71,7 +71,7 @@ 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::direct_thread_id_for_project(root), + thread_id: crate::agent::thread_id_for_project(root), turn_id, sequence: Arc::new(AtomicU64::new(0)), } @@ -168,7 +168,7 @@ impl DirectGameCreatorTurnUpdateEmitter { .unwrap_or_default() .as_millis() .min(u64::MAX as u128) as u64; - update_direct_thread_active_turn( + update_active_turn( &self.thread_id, &self.turn_id, status, 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 new file mode 100644 index 000000000..22e016807 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -0,0 +1,391 @@ +//! DirectProject 的放行:把队首那条待发消息变成一轮真的回合。 +//! +//! 这个模块两件事,别再往里加第三件: +//! 1. [`TurnReservation`]:这一轮的占用对象——持有它代表还没收口,终态出口只认它的 token; +//! 2. [`kick_queue_dispatch`]:认领队首 → 起整轮(落盘用户条目 → 下发 → 跑回合)。 +//! +//! 这一轮的**调用身份**(付费美术、执行会话、MCP、校验、上下文预取读的那个"这一轮是谁")由认领 +//! 在 Thread Manager 上登记,认领成功即成立;这里不再另取一次、也不再持第二份身份。 +//! +//! 放行**不重跑任何入队检查,也没有"放行失败"**:入队时的检查已经判过,这一轮一旦放行,之后的 +//! 一切失败都是**回合失败**,由占用对象收口成 `turn.completed` 的失败载荷。设计见 +//! `docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 + +use std::path::{Path, PathBuf}; + +#[cfg(test)] +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, + run_direct_game_creator_turn_at_with_creation_type_and_emitter, thread_id_for_project, + DirectGameCreatorTurnUpdateEmitter, DirectTurnError, DirectTurnTerminal, DispatchedTurn, +}; + +/// 一次放行的占用。持有它就代表这一轮还没收口。 +/// +/// 生命周期由调用方决定:登记与开始事件已经由 [`kick_queue_dispatch`] 在同一临界区里写好, +/// 这里只接住这一轮的终态出口,并随放行任务一起 drop(正常 / 失败 / panic 都走同一个出口)。 +pub(crate) struct TurnReservation { + thread_id: String, + token: String, + user_item_id: Option, + /// Rust 侧会话保活:只在一条 Direct 回合存活期间运行,随这一轮的占用同生共死。 + /// + /// 渲染层不再按固定间隔触发续期(见 `useDirectProjectChatController` 的说明):会话保活的 + /// 生命周期就是"这一轮在不在跑",占用登记时启动、占用释放(回合收口)时随本对象一起 drop。 + _session_keepalive: Option, +} + +impl TurnReservation { + /// 接管一次放行的占用:占用登记与 `turn.started` 已由 Thread Manager 的认领写好。 + pub(crate) fn resume(thread_id: &str, dispatched: &DispatchedTurn) -> Self { + Self { + thread_id: thread_id.to_string(), + token: dispatched.token.clone(), + user_item_id: dispatched.pending.user_item_id(), + _session_keepalive: crate::auth_session::spawn_client_session_keepalive(), + } + } + + /// 放行之后还没走到深层终态就失败的收口口:只有这一轮仍被自己占用时才写。 + /// + /// 深层(真正跑完这一轮的代码)已经写出终态时返回 `false`,兜底不覆盖真实结果。 + pub(crate) fn finish_if_unfinished(&self, terminal: DirectTurnTerminal) -> bool { + complete_turn_if_reserved( + &self.thread_id, + &self.token, + terminal.event(direct_tool_call_now_ms(), self.user_item_id.as_deref()), + ) + } + + /// 测试用:直接开一轮占用,等价于"入队 + 认领",不经过 Tauri 命令与真实的用户条目。 + #[cfg(test)] + pub(crate) fn accept_for_test(thread_id: &str, client_turn_id: &str) -> Self { + let pending = PendingTurn::new( + client_turn_id.to_string(), + serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "测试消息"}], + "id": format!("direct-codex:{client_turn_id}:user"), + })) + .expect("canonical user item"), + None, + direct_tool_call_now_ms(), + ); + super::enqueue_pending_turn(thread_id, pending).expect("enqueue test turn"); + let dispatched = claim_pending_turn(thread_id).expect("claim test turn"); + Self::resume(thread_id, &dispatched) + } +} + +impl Drop for TurnReservation { + fn drop(&mut self) { + // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 + // 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。 + let _ = self.finish_if_unfinished(DirectTurnTerminal::host_dropped()); + // 占用的释放就是"这个项目腾出了跑回合的位置":踢一脚,队里排着的消息不必等下一次用户动作。 + // panic 也走这里,所以队列不会因为一个任务炸掉而永久停住。 + kick_queue_dispatch(Path::new(&self.thread_id)); + } +} + +/// 踢一脚:让队首那条待发消息有机会变成一轮真的回合。 +/// +/// 幂等,三个调用点:**入队之后**(队列空且没有回合在跑时,放行就是这一脚,不必再等一个调度周期)、 +/// **占用释放**(正常 / 失败 / panic 共用,见 [`TurnReservation`] 的 `Drop`)、**中止路径** +/// (兜底释放时那一轮不会再有人替它收尾)。 +/// +/// 它什么都不返回:没有"放行失败"。踢不动就是现在不该跑——已经有回合在跑,或者队列是空的; +/// 这两种情况都会在下一次释放时再踢。 +pub(crate) fn kick_queue_dispatch(root: &Path) { + let thread_id = thread_id_for_project(root); + let Some(dispatched) = claim_pending_turn(&thread_id) else { + return; + }; + // 放行这一刻的身份代次:这一轮的成绩只在"放行时与终态同代"时才入账(见 `analytics::run`)。 + let release_identity_generation = + crate::platform_session::current_platform_session_write_state().identity_generation; + let reservation = TurnReservation::resume(&thread_id, &dispatched); + let root = root.to_path_buf(); + tauri::async_runtime::spawn(async move { + run_dispatched_direct_turn(root, dispatched, reservation, release_identity_generation) + .await; + }); +} + +/// 放行之后这一段:取这一轮的调用身份 → 落盘用户条目 → 下发 → 跑整轮。 +/// +/// 这一段的每个失败都是**回合失败**:占用与开始事件已经写好,只走占用对象的终态出口,不再回到任何 +/// 命令的返回值上。 +async fn run_dispatched_direct_turn( + root: PathBuf, + dispatched: DispatchedTurn, + reservation: TurnReservation, + release_identity_generation: u64, +) { + let turn_id = dispatched.pending.client_turn_id.clone(); + // 放行只搬运条目自己的事实:canonical 形状与 prompt 都从冻结过的条目重投影,不重算、不写盘。 + let canonical_user_item = serde_json::to_value(&dispatched.pending.user_item) + .expect("冻结过的 canonical 条目一定可序列化"); + let prompt = direct_codex_user_item_to_prompt(&dispatched.pending.user_item); + // 落盘即回合成立:放行之后必须留下这条用户消息,哪怕这一轮随后失败。 + if let Err(error) = append_direct_project_user_message_at(&root, &canonical_user_item) { + // 不继续起整轮:历史是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口用户 + // 消息的助手回复,而且失败会被静默掉。 + let failure = DirectTurnError::EnvironmentNotReady { + detail: redact_agent_runtime_error( + &root, + &format!("写入本项目对话历史失败:{error}"), + 600, + ), + }; + reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &failure)); + return; + } + // 用户条目落盘成功即下发:这一轮从"放行"到"起 codex"之间的一切失败(连不上 app-server、执行器 + // 未通过验收、历史注入失败)都靠它把失败说明挂回自己那一轮。 + crate::agent::codex_app_server::emit_direct_thread_user_item(&root, &canonical_user_item); + let capture = crate::analytics::gui::capture_writer_context(); + let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id); + let outcome = run_direct_game_creator_turn_at_with_creation_type_and_emitter( + &root, + &prompt, + dispatched.pending.creation_type.as_deref(), + Some(&emitter), + Some(canonical_user_item.clone()), + capture, + release_identity_generation, + ) + .await; + match outcome { + Ok(reply) => { + // 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。 + emitter.emit("completed", Some("none"), Some(reply), None); + } + Err(error) => { + // 放行之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。 + // 深层已经写出终态时它不覆盖(同一轮只允许一条终态)。 + reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &error)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{ + consume_thread, enqueue_pending_turn, subscribe_thread, thread_turn_is_active, + DirectTurnFailure, DirectTurnFailureKind, PendingTurn, ThreadEvent, + }; + use uuid::Uuid; + + /// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。 + fn watch(thread_id: &str) -> String { + let bootstrap = subscribe_thread(thread_id); + let _ = consume_thread(&bootstrap.subscription_id); + bootstrap.subscription_id + } + + fn pending(subscription_id: &str) -> Vec { + consume_thread(subscription_id).expect("consume").events + } + + fn turn_completed_events(events: &[ThreadEvent]) -> Vec<&ThreadEvent> { + events + .iter() + .filter(|event| matches!(event, ThreadEvent::TurnCompleted { .. })) + .collect() + } + + /// 一条待发消息。放行路径只读它自己的事实(身份、条目、创建类型),所以这里用最小可用形状。 + fn pending_turn(client_turn_id: &str) -> PendingTurn { + PendingTurn::new( + client_turn_id.to_string(), + serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": format!("消息 {client_turn_id}")}], + "id": format!("direct-codex:{client_turn_id}:user"), + })) + .expect("canonical user item"), + None, + direct_tool_call_now_ms(), + ) + } + + /// 真实存在的项目目录不是这条用例的判据,用唯一的假路径当线程身份即可:认领与占用的语义只认 + /// 这个字符串。 + fn unique_thread(label: &str) -> String { + format!("dispatch-test-{label}-{}", Uuid::new_v4()) + } + + fn enqueue(thread_id: &str, client_turn_id: &str) { + enqueue_pending_turn(thread_id, pending_turn(client_turn_id)).expect("enqueue"); + } + + /// 放行的占用只认**自己的 token**:认领写下的占用身份与这一轮绑死,别人的终态盖不上它。 + #[test] + fn a_foreign_token_cannot_close_the_turn() { + let thread = unique_thread("token"); + enqueue(&thread, "turn-1"); + let dispatched = claim_pending_turn(&thread).expect("claim head"); + let owner = TurnReservation::resume(&thread, &dispatched); + let foreign = TurnReservation::resume( + &thread, + &DispatchedTurn { + token: "foreign-token".to_string(), + pending: pending_turn("turn-1"), + }, + ); + + assert!(!foreign.finish_if_unfinished(DirectTurnTerminal::host_dropped())); + assert!(thread_turn_is_active(&thread)); + assert!(owner.finish_if_unfinished(DirectTurnTerminal::host_dropped())); + assert!(!thread_turn_is_active(&thread)); + // 显式收口之后 Drop 不再补第二条:兜底只负责"没人写过"的那一种。 + drop(foreign); + drop(owner); + } + + /// 深层终态先写,占用对象的兜底就闭嘴:一轮只许有一条终态。 + #[test] + fn the_deep_terminal_wins_and_the_fallback_stays_silent() { + let thread = unique_thread("deep"); + let subscription = watch(&thread); + enqueue(&thread, "turn-1"); + let dispatched = claim_pending_turn(&thread).expect("claim head"); + let reservation = TurnReservation::resume(&thread, &dispatched); + + // 深层收口:真正跑完这一轮的代码算出来的终态。 + let deep = ThreadEvent::turn_completed_failed( + DirectTurnFailure::new( + DirectTurnFailureKind::Timeout, + "等待模型回执超时".to_string(), + ), + 2_000, + ) + .with_user_item_id(Some("direct-codex:turn-1:user")); + crate::agent::complete_turn(&thread, deep); + + assert!( + !reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped()), + "深层已收口时兜底不许再写" + ); + drop(reservation); + + let events = pending(&subscription); + let completed = turn_completed_events(&events); + assert_eq!(completed.len(), 1, "一轮只许有一条终态:{events:?}"); + match completed[0] { + ThreadEvent::TurnCompleted { failure, .. } => { + assert_eq!( + failure.as_ref().map(|f| f.kind), + Some(DirectTurnFailureKind::Timeout) + ); + } + other => panic!("expected a terminal, got {other:?}"), + } + } + + /// 没有终态的 Drop 会补一条 `host-dropped`,并且**踢一脚**让队里排着的下一条接上。 + #[test] + fn dropping_a_reservation_closes_it_and_lets_the_queue_move_on() { + let thread = unique_thread("drop"); + let subscription = watch(&thread); + enqueue(&thread, "turn-1"); + enqueue(&thread, "turn-2"); + let dispatched = claim_pending_turn(&thread).expect("claim head"); + let reservation = TurnReservation::resume(&thread, &dispatched); + + drop(reservation); + + // 第一条按宿主丢弃收口,第二条已经接上:放行不等人,也不必等下一次用户动作。 + let events = pending(&subscription); + let terminal = events + .iter() + .position(|event| matches!(event, ThreadEvent::TurnCompleted { .. })) + .expect("第一条必须被收口"); + assert!( + matches!( + events.get(terminal), + Some(ThreadEvent::TurnCompleted { status, failure: Some(failure), .. }) + if status == "failed" && failure.kind == DirectTurnFailureKind::HostDropped + ), + "{events:?}" + ); + let next_started = events.iter().position(|event| { + matches!( + event, + ThreadEvent::TurnStarted { user_item_id, .. } + if user_item_id.as_deref() == Some("direct-codex:turn-2:user") + ) + }); + assert!( + next_started.is_some_and(|index| index > terminal), + "队列里排着的下一条必须在收口之后立刻放行:{events:?}" + ); + } + + /// 已经有回合在跑、或队列为空时,踢一脚什么都不做。 + #[test] + fn kicking_does_nothing_while_a_turn_is_open_or_the_queue_is_empty() { + let empty = unique_thread("empty"); + kick_queue_dispatch(Path::new(&empty)); + assert!(!thread_turn_is_active(&empty)); + + let busy = unique_thread("busy"); + enqueue(&busy, "turn-1"); + enqueue(&busy, "turn-2"); + let dispatched = claim_pending_turn(&busy).expect("claim head"); + let reservation = TurnReservation::resume(&busy, &dispatched); + + kick_queue_dispatch(Path::new(&busy)); + + // 仍在跑的那一轮没有被顶掉:占用身份还是第一条。 + assert!(thread_turn_is_active(&busy)); + drop(reservation); + } + + /// 前置判据:删掉调用身份守卫之后,"回合任务泄漏、app-server 侧没有可中断句柄"的兜底释放 + /// 仍要能解开占用并把队首放行出去——`d833ca9d3` 的"重进会话被堵死"不得复活。 + #[test] + fn stale_cancel_releases_the_occupancy_and_dispatches_the_next_pending_turn() { + let thread = unique_thread("stale-cancel"); + let subscription = watch(&thread); + // 这一轮已经被放行(占用登记在 Thread Manager 上),但任务泄漏:拿不到可中断句柄。 + let leaked = TurnReservation::accept_for_test(&thread, "turn-stale-1"); + enqueue(&thread, "turn-stale-2"); + + // "从没进执行器"只对过了启动窗口的残留放行;真实用例等不了 60 秒。 + crate::agent::backdate_direct_active_turn_for_test(&thread, 61_000); + let view = + crate::agent::cancel_direct_codex_turn_at(Path::new(&thread), Some("turn-stale-1")) + .expect("stale release"); + assert_eq!( + view.outcome, + crate::agent::codex_app_server::DIRECT_TURN_CANCEL_OUTCOME_RELEASED + ); + assert_eq!(view.client_turn_id, "turn-stale-1"); + + // 占用解开:第一条被兜底收口,第二条接上——终止这一脚就是"继续放行"。 + let events = pending(&subscription); + assert!( + events.iter().any(|event| matches!( + event, + ThreadEvent::TurnCompleted { status, .. } if status == "aborted" + )), + "{events:?}" + ); + assert!( + events.iter().any(|event| matches!( + event, + ThreadEvent::TurnStarted { user_item_id, .. } + if user_item_id.as_deref() == Some("direct-codex:turn-stale-2:user") + )), + "队首必须在兜底释放之后被放行:{events:?}" + ); + drop(leaked); + } +} 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 new file mode 100644 index 000000000..0055d42e5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs @@ -0,0 +1,1852 @@ +//! DirectProject 运行态事件队列。 +//! +//! 这个模块只维护 Thread Manager 的内存事实:每个 thread 一个全局事件序列, +//! 每个 subscriber 一个受保护的消费游标。它不理解前端 reducer,也不负责 JSONL +//! 持久化;调用方必须在完成 item 持久化成功后再追加对应完成事件。 + +pub(crate) mod dispatch; +pub(crate) mod queue; +pub(crate) mod wire; + +pub(crate) use dispatch::*; +pub(crate) use queue::*; +pub(crate) use wire::*; + +use std::collections::{HashMap, HashSet}; +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, +}; + +const DEFAULT_MAX_EVENTS: usize = 8_192; +const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; + +pub(crate) const SUBSCRIPTION_EXPIRED: &str = "SUBSCRIPTION_EXPIRED"; +pub(crate) const THREAD_NOTIFY_EVENT: &str = "game-creator-direct-thread-notify"; + +static THREAD_MANAGER: OnceLock> = OnceLock::new(); +static THREAD_MANAGER_APP_HANDLE: OnceLock = OnceLock::new(); + +/// 队列里的一个事件。`seq` 只服务内部游标,不下发:前端按 `consume` 返回的数组顺序处理。 +#[derive(Clone, Debug)] +struct StoredEvent { + event: ThreadEvent, + seq: u64, + bytes: usize, + cleanable: bool, +} + +#[derive(Clone, Debug)] +struct SubscriberState { + cursor: u64, +} + +/// 一条正在跑的逻辑回合的占用:放行时登记,终态写出时解除。 +/// +/// 它同时是首页「运行中的项目」快照的**唯一事实源**([`list_active_turns`]):这一格的 +/// 生命周期就是"这一轮在不在跑",进度字段由运行时那一侧经 [`update_active_turn`] +/// 回填。任务侧不再另建一张活动回合表——同一件事只许有一处真相。 +/// +/// 两个身份别混: +/// - `token` 是这一次放行的占用身份:终态出口只有拿着同一个 token 的占用对象才能写兜底终态, +/// 避免迟到的旧占用把新回合的边界顶掉。它不对外。 +/// - `turn_id` 是给界面看的回合身份(`clientTurnId` 派生),只服务快照与进度回填的匹配。 +#[derive(Clone, Debug)] +struct ActiveTurn { + token: String, + turn_id: String, + project_name: Option, + started_at: u64, + status: String, + activity: Option, + updated_at: u64, + sequence: u64, +} + +/// 首页「运行中的项目」的一条快照。 +/// +/// `project_path` 与线上其它地方的项目身份取同一个字符串:Thread Manager 的线程身份就是项目的 +/// canonical 路径(见 `thread_id_for_project`),所以快照里的项目身份与事件流里的身份 +/// 永远能对上,不需要调用方再做一次归一。 +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ActiveTurnSnapshot { + pub(crate) project_path: String, + pub(crate) project_name: Option, + pub(crate) turn_id: String, + pub(crate) started_at: u64, + pub(crate) status: String, + pub(crate) activity: Option, + pub(crate) updated_at: u64, + pub(crate) sequence: u64, +} + +/// 一个 thread 上正在跑的那一轮的**只读身份**:给"这一轮是谁"的读者用(付费美术执行会话、 +/// 工具桥、校验预约、上下文预取)。 +/// +/// 身份取自 Thread Manager 的活动回合登记——"这一轮在不在跑"只有这一处真相,界面快照 +/// ([`list_active_turns`])与放行闸门读的也是它,不再另立第二份记录。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct TurnIdentity { + /// 与事件 `turnId` 同一个身份(`clientTurnId`)。 + pub(crate) client_turn_id: String, + /// 这一轮被放行(登记占用)的时刻(Unix 毫秒)。 + pub(crate) started_at: u64, +} + +/// 兜底释放一个残留回合的结果。 +/// +/// 除了 [`Self::Released`],其余三种都是**什么都没写**:占用没动、事件没发。调用方据此给用户 +/// 不同的话;把"校验"和"释放"拆成两步就会丢掉这个性质(见 [`ThreadManager::release_stale_turn`])。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum StaleTurnRelease { + /// 占用已解除,`turn.completed{aborted}` 已下发:这个 thread 现在可以放行下一条。 + Released(String), + /// 这个 thread 上没有未收口的回合。 + NoActiveTurn, + /// 登记的回合身份与调用方给的不一致:绝不误伤另一条回合。 + AnotherTurnRunning, + /// 登记的年龄还没过启动窗口(`age_ms` 是登记到 `now_ms` 的差)。 + WithinStartupWindow { age_ms: u64 }, +} + +/// 一次放行的结果:队首那条待发消息,以及这次放行的占用身份。 +/// +/// `token` 是这一轮的占用身份(终态出口只认它),`pending` 是放行要用的**全部**输入——放行不再 +/// 重跑任何检查,也就不需要再回到调用方取参数。 +#[derive(Clone, Debug)] +pub(crate) struct DispatchedTurn { + pub(crate) token: String, + pub(crate) pending: PendingTurn, +} + +#[derive(Clone, Debug)] +struct ThreadState { + next_seq: u64, + events: Vec, + head: usize, + total_bytes: usize, + active_items: HashSet, + unresolved_requests: HashSet, + /// 未收口的逻辑回合。`None` 表示这个 thread 没有正在跑的回合。 + active_turn: Option, + /// 最近一条 `turn.started` / `turn.completed` 的独立拷贝。 + /// + /// TODO(thread-manager): 这里有意只保留"锚点",因为 replay 队列会回收可回收事件, + /// 队列本身不是完美事件日志——被回收的 `turn.started` / `turn.completed` 不会回放, + /// 只有这份拷贝保证新订阅仍能判定"最新回合是否还在跑"。若将来需要回放多个回合的 + /// 生命周期(回合账本、跨进程恢复、按回合统计),必须另建持久 ledger, + /// 不能靠扩大这份拷贝或放宽回收规则来模拟。 + lifecycle_anchor: Option<(u64, ThreadEvent)>, + subscribers: HashMap, +} + +impl Default for ThreadState { + fn default() -> Self { + Self { + next_seq: 0, + events: Vec::new(), + head: 0, + total_bytes: 0, + active_items: HashSet::new(), + unresolved_requests: HashSet::new(), + active_turn: None, + lifecycle_anchor: None, + subscribers: HashMap::new(), + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct ThreadManager { + threads: HashMap, + max_events: usize, + max_bytes: usize, +} + +impl Default for ThreadManager { + fn default() -> Self { + Self::new() + } +} + +impl ThreadManager { + pub(crate) fn new() -> Self { + Self::with_limits(DEFAULT_MAX_EVENTS, DEFAULT_MAX_BYTES) + } + + fn with_limits(max_events: usize, max_bytes: usize) -> Self { + Self { + threads: HashMap::new(), + max_events: max_events.max(1), + max_bytes: max_bytes.max(1), + } + } + + pub(crate) fn append(&mut self, thread_id: &str, event: ThreadEvent) -> ThreadEvent { + let thread = self.threads.entry(thread_id.to_string()).or_default(); + thread.next_seq = thread.next_seq.saturating_add(1); + let seq = thread.next_seq; + let cleanable = Self::observe_event(thread, seq, &event); + let bytes = serde_json::to_vec(&event) + .map(|value| value.len()) + .unwrap_or_default(); + thread.total_bytes = thread.total_bytes.saturating_add(bytes); + thread.events.push(StoredEvent { + event: event.clone(), + seq, + bytes, + cleanable, + }); + if let Some(item_id) = event.item_id() { + Self::mark_item_events_cleanable(thread, item_id, seq); + } + if let Some(kind) = event.request_kind() { + if kind.is_resolution() { + Self::mark_request_events_cleanable(thread, event.request_id(), seq); + } + } + self.evict(thread_id); + event + } + + pub(crate) fn subscribe(&mut self, thread_id: &str) -> SubscriptionBootstrap { + let thread = self.threads.entry(thread_id.to_string()).or_default(); + let subscription_id = Uuid::new_v4().to_string(); + let cursor = thread.next_seq; + thread + .subscribers + .insert(subscription_id.clone(), SubscriberState { cursor }); + + let mut events = thread + .events + .iter() + .skip(thread.head) + .filter(|stored| Self::is_bootstrap_event(thread, stored)) + .map(|stored| (stored.seq, stored.event.clone())) + .collect::>(); + // 生命周期锚点独立保存:即使队列里那条事件已被回收,也要作为 bootstrap 事件返回。 + if let Some((anchor_seq, anchor)) = thread.lifecycle_anchor.as_ref() { + if !events.iter().any(|(seq, _)| seq == anchor_seq) { + events.push((*anchor_seq, anchor.clone())); + } + } + events.sort_by_key(|(seq, _)| *seq); + SubscriptionBootstrap { + subscription_id, + last_completed_item_id: None, + events: events.into_iter().map(|(_, event)| event).collect(), + } + } + + /// 运行时回填这一轮的进度。只认"仍在跑 + 回合身份一致 + 序号不倒退"的那一次。 + /// + /// 返回快照内容是否真的变了:没有未收口的回合、身份对不上(上一轮迟到的进度)、序号倒退 + /// (乱序到达的旧进度)都必须原地丢弃,不能把快照改成过期的样子;状态与活动都没变的重复 + /// 进度只是推进序号,也不重复通知 GUI。 + fn update_active_turn( + &mut self, + thread_id: &str, + turn_id: &str, + status: &str, + activity: Option<&str>, + sequence: u64, + updated_at: u64, + ) -> bool { + let Some(active) = self + .threads + .get_mut(thread_id) + .and_then(|thread| thread.active_turn.as_mut()) + else { + return false; + }; + if active.turn_id != turn_id || sequence < active.sequence { + return false; + } + let changed = active.status != status || active.activity.as_deref() != activity; + active.status = status.to_string(); + active.activity = activity.map(str::to_string); + active.updated_at = updated_at; + active.sequence = sequence; + changed + } + + /// 首页快照:只导出仍有未收口逻辑回合的 thread。 + fn active_turn_snapshots(&self) -> Vec { + self.threads + .iter() + .filter_map(|(thread_id, thread)| { + let active = thread.active_turn.as_ref()?; + Some(ActiveTurnSnapshot { + project_path: thread_id.clone(), + project_name: active.project_name.clone(), + turn_id: active.turn_id.clone(), + started_at: active.started_at, + status: active.status.clone(), + activity: active.activity.clone(), + updated_at: active.updated_at, + sequence: active.sequence, + }) + }) + .collect() + } + + /// 深层的终态出口:解除占用并写下 `turn.completed`。 + /// + /// 不校验 token:这一条由真正跑完这一轮的代码调用,终态就是它算出来的那个(CLI 这类没有 + /// 占用登记的入口也走这里,保持"终态一定下发"的既有语义)。 + fn complete_turn(&mut self, thread_id: &str, event: ThreadEvent) -> ThreadEvent { + if let Some(thread) = self.threads.get_mut(thread_id) { + thread.active_turn = None; + } + self.append(thread_id, event) + } + + /// 占用对象的兜底出口:只有当这个 thread 仍被同一个 token 占用时才写。 + /// + /// 返回是否真的写了。深层已经写出终态时返回 `false`——兜底不覆盖真实结果。 + fn complete_turn_if_reserved( + &mut self, + thread_id: &str, + token: &str, + event: ThreadEvent, + ) -> bool { + let reserved = match self.threads.get_mut(thread_id) { + Some(thread) => match thread.active_turn.as_ref() { + Some(active) if active.token == token => { + thread.active_turn = None; + true + } + _ => false, + }, + None => false, + }; + if !reserved { + return false; + } + self.append(thread_id, event); + true + } + + /// 兜底释放一个残留回合:**同一个临界区里**做完"校验 → 解除占用 → 写终态"。 + /// + /// 这三件事必须原子。拆成"先校验、后释放"两次取锁的话,两步之间占用可能已经被真实收口释放, + /// 队列顺势认领了下一条并登记新占用——那时再无条件解除占用,就会清掉**新那一轮**的占用,还给 + /// 已经结束的老回合补一条假终态。这正是"多轮同时在跑 / 终态张冠李戴"那一类事故。 + /// + /// 三条前置条件与 [`StaleTurnRelease`] 的三个失败分支一一对应: + /// 1. 这个 thread 上确实登记着一轮; + /// 2. 传了 `expected_client_turn_id` 时必须与登记一致; + /// 3. `min_age_ms` 有值时,登记的年龄(`now_ms - started_at`)必须不小于它——用来排除 + /// "刚放行、还在本地准备"的正常启动窗口。 + /// + /// 不满足任何一条都**一个字都不写**(占用与事件都保持原样)。通过则终态与占用一起落地:占用 + /// 一解除,队列就可以认领下一条。 + fn release_stale_turn( + &mut self, + thread_id: &str, + expected_client_turn_id: Option<&str>, + min_age_ms: Option, + now_ms: u64, + ) -> StaleTurnRelease { + let released = { + let Some(thread) = self.threads.get_mut(thread_id) else { + return StaleTurnRelease::NoActiveTurn; + }; + let Some(active) = thread.active_turn.take() else { + return StaleTurnRelease::NoActiveTurn; + }; + if expected_client_turn_id.is_some_and(|expected| expected != active.turn_id.as_str()) { + // 身份对不上:放回去,绝不误伤另一条回合。 + thread.active_turn = Some(active); + return StaleTurnRelease::AnotherTurnRunning; + } + let age_ms = now_ms.saturating_sub(active.started_at); + if min_age_ms.is_some_and(|min_age_ms| age_ms < min_age_ms) { + thread.active_turn = Some(active); + return StaleTurnRelease::WithinStartupWindow { age_ms }; + } + active.turn_id + }; + // 这一轮不会再有人替它发终态(执行进程已退出 / 从没进执行器):补一条 aborted,否则前端的 + // "最新回合是否在跑"会永远停在运行中。身份用被释放那一轮自己的 `turn_id` 推导,与放行时 + // `turn.started` 的口径同一份(`PendingTurn::user_item_id`)。 + self.append( + thread_id, + ThreadEvent::turn_completed("aborted".to_string(), now_ms).with_user_item_id( + direct_codex_user_item_id_for_client_turn_id(&released).as_deref(), + ), + ); + StaleTurnRelease::Released(released) + } + + /// 这个 thread 上正在跑的那一轮身份;`None` = 没有未收口的回合。 + fn active_turn_identity(&self, thread_id: &str) -> Option { + self.threads.get(thread_id).and_then(|thread| { + thread.active_turn.as_ref().map(|active| TurnIdentity { + client_turn_id: active.turn_id.clone(), + started_at: active.started_at, + }) + }) + } + + fn turn_is_active(&self, thread_id: &str) -> bool { + self.threads + .get(thread_id) + .is_some_and(|thread| thread.active_turn.is_some()) + } + + /// 在队的待发消息,队首在前。 + /// + /// 队列就是事件列表:一条待发消息在队 = 事件窗口里"有 `queue.enqueued`、没有配对 + /// `queue.removed`"。一次折叠既判成员也定顺序,不需要第二张表;在队条目的入队事件永不可回收, + /// 所以它不会被前缀回收吃掉(见 `thread_manager::queue` 与 [`Self::observe_event`])。 + fn pending_turns(thread: &ThreadState) -> Vec { + let mut pending: Vec = Vec::new(); + for stored in thread.events.iter().skip(thread.head) { + match &stored.event { + ThreadEvent::QueueEnqueued { .. } => { + if let Some(turn) = PendingTurn::from_event(&stored.event) { + pending.push(turn); + } + } + ThreadEvent::QueueRemoved { client_turn_id, .. } => { + pending.retain(|turn| &turn.client_turn_id != client_turn_id); + } + _ => {} + } + } + pending + } + + /// 入队:把一条已经通过入队检查的待发消息排到队尾。 + /// + /// 幂等按 `clientTurnId`,判重范围是「**在队 ∪ 正在跑的那一轮**」:同一身份重复入队返回 + /// [`EnqueueOutcome::AlreadyKnown`],不排第二条、不发第二条事件。容量只数在队条目。 + fn enqueue_pending_turn( + &mut self, + thread_id: &str, + turn: PendingTurn, + ) -> Result { + let event = turn.enqueued_event(); + { + let thread = self.threads.entry(thread_id.to_string()).or_default(); + let already_known = Self::pending_turns(thread) + .iter() + .any(|pending| pending.client_turn_id == turn.client_turn_id) + || thread + .active_turn + .as_ref() + .is_some_and(|active| active.turn_id == turn.client_turn_id); + if already_known { + return Ok(EnqueueOutcome::AlreadyKnown); + } + queue_has_room(Self::pending_turns(thread).len())?; + } + self.append(thread_id, event); + Ok(EnqueueOutcome::Enqueued) + } + + /// 取消一条待发消息。typed 结果告诉调用方它到底是被取消、已经放行,还是本来就不在队里。 + /// + /// 判「已经被放行」用的是正在跑的那一轮的回合身份:认领之后就再也按待发消息取消不了了。 + fn remove_pending_turn( + &mut self, + thread_id: &str, + client_turn_id: &str, + at_ms: u64, + ) -> QueueRemovalOutcome { + let Some(thread) = self.threads.get(thread_id) else { + return QueueRemovalOutcome::NotFound; + }; + if thread + .active_turn + .as_ref() + .is_some_and(|active| active.turn_id == client_turn_id) + { + return QueueRemovalOutcome::AlreadyDispatched; + } + if !Self::pending_turns(thread) + .iter() + .any(|pending| pending.client_turn_id == client_turn_id) + { + return QueueRemovalOutcome::NotFound; + } + // 离开队列这件事由 `queue.removed` 自己表达:追加即出队(同一条入队事件就此转成可回收), + // 所以入队成功之后这里一定找得到那一条。 + self.append( + thread_id, + ThreadEvent::queue_removed( + client_turn_id.to_string(), + QueueRemovalReason::Cancelled, + at_ms, + ), + ); + QueueRemovalOutcome::Removed + } + + /// 放行:同一个临界区里取队首 → 登记占用 → 写 `turn.started` 与 `queue.removed{dispatched}`。 + /// + /// 返回 `None` 表示现在**不该放行**:这个 thread 已经有未收口的回合,或者队列是空的——两件事 + /// 都由这里判,调用方(kick)不必先探一遍再决定。认领认的永远是队首:待发消息的顺序就是事件 + /// 顺序,没有第二种来源。 + fn claim_pending_turn( + &mut self, + thread_id: &str, + started_at_ms: u64, + ) -> Option { + let pending = { + let thread = self.threads.get(thread_id)?; + if thread.active_turn.is_some() { + return None; + } + Self::pending_turns(thread).into_iter().next()? + }; + let token = Uuid::new_v4().to_string(); + let turn_id = pending.client_turn_id.clone(); + let project_name = std::path::Path::new(thread_id) + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string); + if let Some(thread) = self.threads.get_mut(thread_id) { + thread.active_turn = Some(ActiveTurn { + token: token.clone(), + turn_id: turn_id.clone(), + project_name, + started_at: started_at_ms, + // 与"还没有任何进度事件"的状态一致:运行时给出的第一条进度会覆盖它。 + status: "accepted".to_string(), + activity: Some("request-accepted".to_string()), + updated_at: started_at_ms, + sequence: 0, + }); + } + let user_item_id = pending.user_item_id(); + self.append( + thread_id, + ThreadEvent::turn_started(started_at_ms).with_user_item_id(user_item_id.as_deref()), + ); + self.append( + thread_id, + ThreadEvent::queue_removed(turn_id, QueueRemovalReason::Dispatched, started_at_ms), + ); + Some(DispatchedTurn { token, pending }) + } + + fn subscriber_ids(&self, thread_id: &str) -> Vec { + self.threads + .get(thread_id) + .map(|thread| thread.subscribers.keys().cloned().collect()) + .unwrap_or_default() + } + + pub(crate) fn consume(&mut self, subscription_id: &str) -> Result { + let Some((_, thread)) = self + .threads + .iter_mut() + .find(|(_, thread)| thread.subscribers.contains_key(subscription_id)) + else { + return Err(SUBSCRIPTION_EXPIRED.to_string()); + }; + let cursor = thread + .subscribers + .get(subscription_id) + .map(|subscriber| subscriber.cursor) + .expect("subscriber checked above"); + let oldest_seq = thread + .events + .get(thread.head) + .map(|stored| stored.seq) + .unwrap_or(thread.next_seq.saturating_add(1)); + if cursor.saturating_add(1) < oldest_seq { + thread.subscribers.remove(subscription_id); + return Err(SUBSCRIPTION_EXPIRED.to_string()); + } + let mut last_seq = cursor; + let events = thread + .events + .iter() + .skip(thread.head) + .filter(|stored| stored.seq > cursor) + .map(|stored| { + last_seq = stored.seq; + stored.event.clone() + }) + .collect::>(); + thread + .subscribers + .get_mut(subscription_id) + .expect("subscriber remains registered") + .cursor = last_seq; + let result = ConsumeResult { events }; + Self::trim_prefix(thread); + Ok(result) + } + + /// 移除订阅者并立即释放它钉住的队列前缀。重复移除是幂等空操作,允许前端 + /// cleanup 与迟到的 bootstrap 回执安全竞争。 + pub(crate) fn unsubscribe(&mut self, subscription_id: &str) -> bool { + let Some((_, thread)) = self + .threads + .iter_mut() + .find(|(_, thread)| thread.subscribers.contains_key(subscription_id)) + else { + return false; + }; + let removed = thread.subscribers.remove(subscription_id).is_some(); + if removed { + Self::trim_prefix(thread); + } + removed + } + + #[cfg(test)] + fn thread_debug(&self, thread_id: &str) -> Option<(usize, usize, usize)> { + self.threads.get(thread_id).map(|thread| { + ( + thread.events.len().saturating_sub(thread.head), + thread.total_bytes, + thread.subscribers.len(), + ) + }) + } + + /// 当前在队的待发消息身份,队首在前。 + #[cfg(test)] + fn pending_turn_ids(&self, thread_id: &str) -> Vec { + self.threads + .get(thread_id) + .map(|thread| { + Self::pending_turns(thread) + .iter() + .map(|pending| pending.client_turn_id.clone()) + .collect() + }) + .unwrap_or_default() + } + + /// 当前在队的待发消息条数(容量预判用,只读)。 + fn pending_turn_count(&self, thread_id: &str) -> usize { + self.threads + .get(thread_id) + .map(|thread| Self::pending_turns(thread).len()) + .unwrap_or(0) + } + + /// 观察一条事件,返回它本身是否可回收。 + fn observe_event(thread: &mut ThreadState, seq: u64, event: &ThreadEvent) -> bool { + match event { + ThreadEvent::ItemStarted { item, .. } => { + thread.active_items.insert(item.item_id().to_string()); + false + } + ThreadEvent::ItemCompleted { item, .. } => { + thread.active_items.remove(item.item_id()); + true + } + ThreadEvent::TurnStarted { .. } | ThreadEvent::TurnCompleted { .. } => { + // 队列只保留最新一条生命周期事件,更早的可能已被回收;见 + // `ThreadState::lifecycle_anchor` 的 TODO:这不是完整事件日志。 + thread.lifecycle_anchor = Some((seq, event.clone())); + true + } + ThreadEvent::Request { + kind, request_id, .. + } => { + if kind.is_request() { + if let Some(request_id) = request_id.as_deref() { + thread.unresolved_requests.insert(request_id.to_string()); + } + // 没有 request id 的请求事件无法配对,直接视为可回收。 + return request_id.is_none(); + } + if let Some(request_id) = request_id.as_deref() { + thread.unresolved_requests.remove(request_id); + } + true + } + ThreadEvent::ItemDelta { .. } => true, + // 待发消息在队期间不可回收:队列的成员与顺序**就是**这条事件本身 + // (见 `thread_manager::queue`)。bootstrap 因此天然看得见当前队列。 + ThreadEvent::QueueEnqueued { .. } => false, + // 离开队列(取消 / 放行)才把同一条待发消息的入队事件一起转成可回收。 + ThreadEvent::QueueRemoved { client_turn_id, .. } => { + Self::mark_queue_events_cleanable(thread, client_turn_id); + true + } + } + } + + /// 新订阅此刻需要补的事件:未完成 item 的完整事件、未解决请求、不可回收的事件。 + fn is_bootstrap_event(thread: &ThreadState, stored: &StoredEvent) -> bool { + if let Some(item_id) = stored.event.item_id() { + return thread.active_items.contains(item_id); + } + if let Some(request_id) = stored.event.request_id() { + return thread.unresolved_requests.contains(request_id); + } + // 生命周期锚点单独补,增量正文这类瞬时事件不回放。 + !stored.cleanable + } + + fn mark_item_events_cleanable(thread: &mut ThreadState, item_id: &str, seq: u64) { + if thread.active_items.contains(item_id) { + return; + } + for stored in &mut thread.events { + if stored.seq <= seq && stored.event.item_id() == Some(item_id) { + stored.cleanable = true; + } + } + } + + fn mark_request_events_cleanable(thread: &mut ThreadState, request_id: Option<&str>, seq: u64) { + let Some(resolved_request_id) = request_id else { + return; + }; + for stored in &mut thread.events { + if stored.seq <= seq + && stored + .event + .request_kind() + .is_some_and(|kind| kind.is_request()) + && stored.event.request_id() == Some(resolved_request_id) + { + stored.cleanable = true; + } + } + } + + /// 一条待发消息离开队列:它和它对应的入队事件一起变成可回收。 + fn mark_queue_events_cleanable(thread: &mut ThreadState, client_turn_id: &str) { + for stored in &mut thread.events { + // 配对的两条一起转可回收——在队条目的入队事件本来就不会走到这里。 + if stored.event.queue_client_turn_id() == Some(client_turn_id) { + stored.cleanable = true; + } + } + } + + fn trim_prefix(thread: &mut ThreadState) { + let min_cursor = thread + .subscribers + .values() + .map(|subscriber| subscriber.cursor) + .min() + .unwrap_or(thread.next_seq); + loop { + let can_pop = thread + .events + .get(thread.head) + .is_some_and(|stored| stored.seq <= min_cursor && stored.cleanable); + if !can_pop { + break; + } + let bytes = thread + .events + .get(thread.head) + .map(|stored| stored.bytes) + .unwrap_or_default(); + thread.total_bytes = thread.total_bytes.saturating_sub(bytes); + thread.head = thread.head.saturating_add(1); + } + Self::compact(thread); + } + + fn compact(thread: &mut ThreadState) { + if thread.head >= 1024 && thread.head.saturating_mul(2) >= thread.events.len() { + thread.events.drain(..thread.head); + thread.head = 0; + } + } + + fn evict(&mut self, thread_id: &str) { + loop { + let over_limit = self.threads.get(thread_id).is_some_and(|thread| { + thread.events.len().saturating_sub(thread.head) > self.max_events + || thread.total_bytes > self.max_bytes + }); + if !over_limit { + if let Some(thread) = self.threads.get_mut(thread_id) { + Self::trim_prefix(thread); + } + return; + } + let Some(thread) = self.threads.get_mut(thread_id) else { + return; + }; + let oldest_seq = thread + .events + .get(thread.head) + .map(|stored| stored.seq) + .unwrap_or(thread.next_seq.saturating_add(1)); + let slowest = thread + .subscribers + .iter() + .filter(|(_, subscriber)| subscriber.cursor < oldest_seq) + .min_by_key(|(_, subscriber)| subscriber.cursor) + .map(|(id, _)| id.clone()); + if let Some(subscription_id) = slowest { + thread.subscribers.remove(&subscription_id); + Self::trim_prefix(thread); + continue; + } + // 未完成 item 的事件可能暂时 pin 住队头;不能为了满足上限截断它。 + return; + } + } +} + +fn global_thread_manager() -> &'static Mutex { + THREAD_MANAGER.get_or_init(|| Mutex::new(ThreadManager::new())) +} + +pub(crate) fn set_thread_manager_app_handle(app: tauri::AppHandle) { + let _ = THREAD_MANAGER_APP_HANDLE.set(app); +} + +pub(crate) fn append_thread_event(thread_id: &str, event: ThreadEvent) -> ThreadEvent { + let event = { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .append(thread_id, event) + }; + notify_subscribers(thread_id); + event +} + +/// 入队一条待发消息(队列的成员与顺序就是事件列表,见 `thread_manager::queue`)。 +pub(crate) fn enqueue_pending_turn( + thread_id: &str, + turn: PendingTurn, +) -> Result { + let outcome = { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .enqueue_pending_turn(thread_id, turn) + }; + if matches!(outcome, Ok(EnqueueOutcome::Enqueued)) { + notify_subscribers(thread_id); + } + outcome +} + +/// 这个 thread 上**在队**的待发消息条数(只读)。 +/// +/// 给入队侧做最便宜的一次容量预判用:满了就先拒绝,不去做工程准备那种分钟级、会在磁盘上留下产物的 +/// 活。权威判据仍然是入队临界区里的 `queue_has_room`,这里只求早失败。 +pub(crate) fn pending_turn_count(thread_id: &str) -> usize { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .pending_turn_count(thread_id) +} + +/// 取消一条待发消息:结果告诉调用方它是被取消、已经放行,还是本来就不在队里。 +pub(crate) fn remove_pending_turn(thread_id: &str, client_turn_id: &str) -> QueueRemovalOutcome { + let outcome = { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove_pending_turn(thread_id, client_turn_id, direct_tool_call_now_ms()) + }; + if outcome == QueueRemovalOutcome::Removed { + notify_subscribers(thread_id); + } + outcome +} + +/// 认领队首并登记占用(放行的原子步骤,见 [`ThreadManager::claim_pending_turn`])。 +/// +/// `None` = 现在不该放行:已有未收口的回合,或队列为空。 +/// +/// 认领成功就是首页「运行中的项目」快照变了的那一刻:放行这一步同时登记占用,所以在这里广播 +/// [`crate::agent::emit_direct_active_turns_changed`]。入队与取消只改队列,不进快照,不广播。 +pub(crate) fn claim_pending_turn(thread_id: &str) -> Option { + let claimed = { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .claim_pending_turn(thread_id, direct_tool_call_now_ms()) + }; + if claimed.is_some() { + notify_subscribers(thread_id); + crate::agent::emit_direct_active_turns_changed(); + } + claimed +} + +/// 运行时回填某一轮逻辑回合的进度(状态 / 活动 / 序号)。返回快照内容是否真的变了。 +pub(crate) fn update_active_turn( + thread_id: &str, + turn_id: &str, + status: &str, + activity: Option<&str>, + sequence: u64, + updated_at: u64, +) -> bool { + let changed = global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .update_active_turn(thread_id, turn_id, status, activity, sequence, updated_at); + if changed { + crate::agent::emit_direct_active_turns_changed(); + } + changed +} + +/// 首页「运行中的项目」快照:逻辑回合的唯一导出口(见 [`ActiveTurnSnapshot`])。 +pub(crate) fn list_active_turns() -> Result, String> { + let mut turns = global_thread_manager() + .lock() + .map_err(|_| "Direct 线程管理器已损坏".to_string())? + .active_turn_snapshots(); + turns.sort_by(|left, right| left.project_path.cmp(&right.project_path)); + Ok(turns) +} + +/// 深层终态出口:解除占用并写 `turn.completed`。 +pub(crate) fn complete_turn(thread_id: &str, event: ThreadEvent) { + { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .complete_turn(thread_id, event); + } + notify_subscribers(thread_id); + crate::agent::emit_direct_active_turns_changed(); +} + +/// 占用对象的兜底出口:仍被同一 token 占用时才写,返回是否写了。 +pub(crate) fn complete_turn_if_reserved(thread_id: &str, token: &str, event: ThreadEvent) -> bool { + let written = { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .complete_turn_if_reserved(thread_id, token, event) + }; + if written { + notify_subscribers(thread_id); + crate::agent::emit_direct_active_turns_changed(); + } + written +} + +/// 兜底释放一个残留回合:校验与释放**同一个临界区**(见 [`ThreadManager::release_stale_turn`])。 +/// +/// 只有真释放了才通知订阅者:校验不过时什么都没写,不该惊动任何人。 +pub(crate) fn release_stale_direct_turn( + thread_id: &str, + expected_client_turn_id: Option<&str>, + min_age_ms: Option, +) -> StaleTurnRelease { + let outcome = { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .release_stale_turn( + thread_id, + expected_client_turn_id, + min_age_ms, + direct_tool_call_now_ms(), + ) + }; + if matches!(outcome, StaleTurnRelease::Released(_)) { + notify_subscribers(thread_id); + } + outcome +} + +/// 这个 thread 上正在跑的那一轮身份。 +/// +/// 只读:占用仍然只由终态出口(深层终态 / 占用对象兜底 / 兜底释放)解除,这里不改任何状态。 +pub(crate) fn read_turn_identity(thread_id: &str) -> Option { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .active_turn_identity(thread_id) +} + +/// 这个 thread 是否还有没收口的逻辑回合。 +pub(crate) fn thread_turn_is_active(thread_id: &str) -> bool { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .turn_is_active(thread_id) +} + +/// 测试用:把某 thread 上正在跑那一轮的登记时刻往前拨 `age_ms`。 +/// +/// 只服务"启动窗口闸门"这一条判据:终止的兜底释放只对"登记已存在很久"的残留放行, +/// 真实用例等不了 60 秒。 +#[cfg(test)] +pub(crate) fn backdate_direct_active_turn_for_test(thread_id: &str, age_ms: u64) { + let mut manager = global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(active) = manager + .threads + .get_mut(thread_id) + .and_then(|thread| thread.active_turn.as_mut()) + { + active.started_at = active.started_at.saturating_sub(age_ms); + } +} + +fn notify_subscribers(thread_id: &str) { + let subscriber_ids = { + let manager = global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + manager.subscriber_ids(thread_id) + }; + if let Some(app) = THREAD_MANAGER_APP_HANDLE.get() { + for subscription_id in subscriber_ids { + let _ = tauri::Emitter::emit( + app, + THREAD_NOTIFY_EVENT, + serde_json::json!({ "subscriptionId": subscription_id }), + ); + } + } +} + +pub(crate) fn subscribe_thread(thread_id: &str) -> SubscriptionBootstrap { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .subscribe(thread_id) +} + +pub(crate) fn consume_thread(subscription_id: &str) -> Result { + global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .consume(subscription_id) +} + +pub(crate) fn unsubscribe_thread(subscription_id: &str) { + let mut manager = global_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + manager.unsubscribe(subscription_id); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{ + direct_codex_user_item_to_prompt, ThreadDeltaKind, ThreadItem, ThreadRequestKind, + MAX_PENDING_TURNS, + }; + + fn message(item_id: &str) -> ThreadItem { + ThreadItem::Message { + item_id: item_id.to_string(), + role: "assistant".to_string(), + text: "内容".to_string(), + at: 0, + } + } + + /// 事件级阶段时间只在重放稳定性用例里逐个指定;其余用例用一个固定值即可, + /// 它们断言的是队列 / 游标语义,不是时间本身。 + const FIXED_AT_MS: u64 = 1_000; + + fn item_started(item_id: &str) -> ThreadEvent { + ThreadEvent::item_started(message(item_id), FIXED_AT_MS) + } + + fn item_completed(item_id: &str) -> ThreadEvent { + ThreadEvent::item_completed(message(item_id), FIXED_AT_MS) + } + + fn item_delta(item_id: &str) -> ThreadEvent { + ThreadEvent::item_delta( + item_id.to_string(), + ThreadDeltaKind::Message, + "增量".to_string(), + ) + } + + fn request(kind: ThreadRequestKind, request_id: Option<&str>) -> ThreadEvent { + ThreadEvent::request(kind, request_id.map(str::to_string)) + } + + #[test] + fn subscribers_have_independent_cursors_on_one_global_queue() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append("thread-1", ThreadEvent::turn_started(FIXED_AT_MS)); + let first = manager.subscribe("thread-1"); + let second = manager.subscribe("thread-1"); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); + + let first_batch = manager + .consume(&first.subscription_id) + .expect("first consume"); + assert_eq!(first_batch.events.len(), 2); + let second_batch = manager + .consume(&second.subscription_id) + .expect("second consume"); + assert_eq!(second_batch.events, first_batch.events); + assert!(manager + .consume(&first.subscription_id) + .expect("empty consume") + .events + .is_empty()); + } + + #[test] + fn unsubscribe_releases_the_subscriber_and_is_idempotent() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append("thread-1", item_started("item-1")); + let subscription = manager.subscribe("thread-1"); + manager.append("thread-1", item_delta("item-1")); + + assert!(manager.unsubscribe(&subscription.subscription_id)); + assert!(!manager.unsubscribe(&subscription.subscription_id)); + assert_eq!( + manager.consume(&subscription.subscription_id), + Err(SUBSCRIPTION_EXPIRED.to_string()) + ); + } + + #[test] + fn unsubscribe_allows_a_cleanable_prefix_to_be_trimmed() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append("thread-1", item_started("item-1")); + let subscription = manager.subscribe("thread-1"); + manager.append("thread-1", item_completed("item-1")); + assert_eq!(manager.thread_debug("thread-1").unwrap().0, 1); + + manager.unsubscribe(&subscription.subscription_id); + + assert_eq!(manager.thread_debug("thread-1").unwrap().0, 0); + } + + #[test] + fn bootstrap_contains_lifecycle_anchor_and_unfinished_events_only() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append("thread-1", ThreadEvent::turn_started(FIXED_AT_MS)); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); + manager.append("thread-1", item_completed("item-1")); + manager.append("thread-1", item_started("item-2")); + + let bootstrap = manager.subscribe("thread-1"); + assert!(matches!( + bootstrap.events.as_slice(), + [ + ThreadEvent::TurnStarted { .. }, + ThreadEvent::ItemStarted { item, .. }, + ] if item.item_id() == "item-2" + )); + } + + #[test] + fn completion_releases_item_events_only_after_the_completion_event_is_appended() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); + let bootstrap = manager.subscribe("thread-1"); + manager.append("thread-1", item_completed("item-1")); + let events = manager + .consume(&bootstrap.subscription_id) + .expect("consume completion") + .events; + assert!(matches!( + events.as_slice(), + [ThreadEvent::ItemCompleted { item, .. }] if item.item_id() == "item-1" + )); + } + + #[test] + fn slow_subscriber_is_expired_when_queue_limit_is_reached() { + let mut manager = ThreadManager::with_limits(2, 100_000); + let subscription = manager.subscribe("thread-1"); + for _ in 0..3 { + manager.append( + "thread-1", + request(ThreadRequestKind::RequestResolved, None), + ); + } + assert_eq!( + manager.consume(&subscription.subscription_id), + Err(SUBSCRIPTION_EXPIRED.to_string()) + ); + } + + #[test] + fn current_subscriber_is_not_expired_by_pinned_queue_head() { + let mut manager = ThreadManager::with_limits(2, 100_000); + manager.append("thread-1", item_started("item-1")); + let subscription = manager.subscribe("thread-1"); + for _ in 0..3 { + manager.append("thread-1", item_delta("item-1")); + } + assert_ne!( + manager.consume(&subscription.subscription_id), + Err(SUBSCRIPTION_EXPIRED.to_string()) + ); + } + + #[test] + fn unresolved_approval_is_kept_in_bootstrap_until_resolved() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append( + "thread-1", + request(ThreadRequestKind::ApprovalRequested, Some("request-1")), + ); + let bootstrap = manager.subscribe("thread-1"); + assert_eq!(bootstrap.events.len(), 1); + manager.append( + "thread-1", + request(ThreadRequestKind::RequestResolved, Some("request-1")), + ); + assert_eq!( + manager + .consume(&bootstrap.subscription_id) + .expect("consume resolution") + .events + .len(), + 1 + ); + } + + #[test] + fn resolved_request_releases_the_original_requested_event() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append( + "thread-1", + request(ThreadRequestKind::ApprovalRequested, Some("request-1")), + ); + let subscription = manager.subscribe("thread-1"); + manager.append( + "thread-1", + request(ThreadRequestKind::RequestResolved, Some("request-1")), + ); + manager + .consume(&subscription.subscription_id) + .expect("consume resolution"); + assert_eq!(manager.thread_debug("thread-1").unwrap().0, 0); + } + + #[test] + fn turn_completed_anchor_survives_empty_queue_for_new_subscriber() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append( + "thread-1", + ThreadEvent::turn_completed("completed".to_string(), FIXED_AT_MS), + ); + let bootstrap = manager.subscribe("thread-1"); + assert!(matches!( + bootstrap.events.as_slice(), + [ThreadEvent::TurnCompleted { status, at, .. }] + if status == "completed" && *at == Some(FIXED_AT_MS) + )); + } + + /// 失败终态与正常终态同权:`turn.completed(status="failed")` 必须顶替更早的 `turn.started` + /// 成为锚点,否则队列被回收后新订阅只会看到 `turn.started`,把这轮已收口的回合重放成"还在跑"。 + #[test] + fn failed_turn_completed_replaces_started_anchor() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append("thread-1", ThreadEvent::turn_started(1_000)); + manager.append( + "thread-1", + ThreadEvent::turn_completed_failed( + crate::agent::DirectTurnFailure::new( + crate::agent::DirectTurnFailureKind::HostDropped, + "回合宿主任务提前结束", + ), + FIXED_AT_MS, + ), + ); + + let bootstrap = manager.subscribe("thread-1"); + assert!(matches!( + bootstrap.events.as_slice(), + [ThreadEvent::TurnCompleted { status, failure, at, .. }] + if status == "failed" + && failure.as_ref().is_some_and(|failure| failure.kind + == crate::agent::DirectTurnFailureKind::HostDropped) + && *at == Some(FIXED_AT_MS) + )); + } + + /// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**, + /// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。 + #[test] + fn replayed_events_keep_their_original_stage_time() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append("thread-1", ThreadEvent::turn_started(1_000)); + manager.append( + "thread-1", + ThreadEvent::item_started(message("item-1"), 2_000), + ); + + let first = manager.subscribe("thread-1"); + assert_eq!( + first.events.iter().map(ThreadEvent::at).collect::>(), + vec![Some(1_000), Some(2_000)] + ); + + // 第二个订阅看到的是同一份事件,时间不因"又取了一次当前时间"而漂移。 + let second = manager.subscribe("thread-1"); + assert_eq!(second.events, first.events); + + manager.append( + "thread-1", + ThreadEvent::item_completed(message("item-1"), 3_000), + ); + let completion = manager + .consume(&first.subscription_id) + .expect("consume completion") + .events; + assert_eq!( + completion.iter().map(ThreadEvent::at).collect::>(), + vec![Some(3_000)] + ); + // 重复消费不产生新事件,也不改写已下发过的时间。 + assert!(manager + .consume(&first.subscription_id) + .expect("empty consume") + .events + .is_empty()); + assert_eq!( + completion.iter().map(ThreadEvent::at).collect::>(), + vec![Some(3_000)] + ); + } + + /// 生命周期锚点重放时必须带上开口用户条目身份:前端在「只有锚点 + 历史切片、运行态一直空」 + /// 的回合里也要能把边界认领给同一条用户条目,而不是按时间戳猜。 + #[test] + fn bootstrap_replays_opener_user_item_id() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append( + "thread-1", + ThreadEvent::turn_started(1_000).with_user_item_id(Some("direct-codex:turn-1:user")), + ); + let bootstrap = manager.subscribe("thread-1"); + assert_eq!( + bootstrap + .events + .iter() + .map(ThreadEvent::user_item_id) + .collect::>(), + vec![Some("direct-codex:turn-1:user")] + ); + assert_eq!(bootstrap.events[0].at(), Some(1_000)); + + // 锚点是独立保存的副本:队列里那条事件被回收之后,新订阅仍拿到同一个身份。 + manager + .consume(&bootstrap.subscription_id) + .expect("consume anchor"); + manager.append( + "thread-1", + ThreadEvent::item_completed(message("item-1"), 2_000), + ); + manager.append( + "thread-1", + ThreadEvent::turn_completed("completed".to_string(), 3_000) + .with_user_item_id(Some("direct-codex:turn-1:user")), + ); + let second = manager.subscribe("thread-1"); + assert_eq!( + second + .events + .iter() + .map(ThreadEvent::user_item_id) + .collect::>(), + vec![Some("direct-codex:turn-1:user")], + "起止同源:终态锚点也带同一个开口用户条目身份" + ); + assert_eq!(second.events[0].at(), Some(3_000)); + } + + #[test] + fn queue_cleanup_only_removes_a_cleanable_prefix() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager.append("thread-1", item_started("item-1")); + manager.append( + "thread-1", + request(ThreadRequestKind::RequestResolved, None), + ); + let subscription = manager.subscribe("thread-1"); + manager + .consume(&subscription.subscription_id) + .expect("consume"); + let (events, _, _) = manager.thread_debug("thread-1").expect("thread"); + assert_eq!( + events, 2, + "unfinished item at queue head blocks middle cleanup" + ); + } + + /// 首页快照就是逻辑回合的导出:放行即出现、进度按序号回填、收口即消失。 + fn snapshot_of(manager: &ThreadManager, thread_id: &str) -> Option { + manager + .active_turn_snapshots() + .into_iter() + .find(|turn| turn.project_path == thread_id) + } + + #[test] + fn active_turn_snapshot_follows_the_logical_turn_lifecycle() { + let mut manager = ThreadManager::with_limits(100, 100_000); + let thread_id = "/tmp/快照项目"; + assert!(snapshot_of(&manager, thread_id).is_none()); + + manager + .enqueue_pending_turn(thread_id, pending_turn("turn-1")) + .expect("enqueue"); + manager + .claim_pending_turn(thread_id, FIXED_AT_MS) + .expect("claim head"); + let accepted = snapshot_of(&manager, thread_id).expect("accepted turn is visible"); + assert_eq!(accepted.turn_id, "turn-1"); + assert_eq!(accepted.project_name.as_deref(), Some("快照项目")); + assert_eq!(accepted.started_at, FIXED_AT_MS); + assert_eq!(accepted.status, "accepted"); + assert_eq!(accepted.activity.as_deref(), Some("request-accepted")); + assert_eq!(accepted.sequence, 0); + + assert!(manager.update_active_turn( + thread_id, + "turn-1", + "streaming", + Some("file-write"), + 3, + 42, + )); + let running = snapshot_of(&manager, thread_id).expect("running turn is visible"); + assert_eq!(running.status, "streaming"); + assert_eq!(running.activity.as_deref(), Some("file-write")); + assert_eq!(running.sequence, 3); + assert_eq!(running.updated_at, 42); + + // 序号倒退与身份对不上的进度都不许改快照。 + assert!(!manager.update_active_turn(thread_id, "turn-1", "failed", None, 2, 99)); + assert!(!manager.update_active_turn(thread_id, "turn-2", "failed", None, 4, 99)); + assert_eq!( + snapshot_of(&manager, thread_id) + .expect("snapshot unchanged") + .status, + "streaming" + ); + + manager.complete_turn( + thread_id, + ThreadEvent::turn_completed("completed".to_string(), 5_000), + ); + assert!(snapshot_of(&manager, thread_id).is_none()); + } + + fn pending_turn(client_turn_id: &str) -> PendingTurn { + PendingTurn::new( + client_turn_id.to_string(), + serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": format!("消息 {client_turn_id}")}], + "id": format!("direct-codex:{client_turn_id}:user"), + })) + .expect("canonical user item"), + None, + FIXED_AT_MS, + ) + } + + fn enqueued_ids(events: &[ThreadEvent]) -> Vec<&str> { + events + .iter() + .filter_map(|event| match event { + ThreadEvent::QueueEnqueued { client_turn_id, .. } => Some(client_turn_id.as_str()), + _ => None, + }) + .collect() + } + + /// 队列就是事件列表:新订阅者的 bootstrap 看得见当前队列,取消之后那条就从队列里消失。 + #[test] + fn new_subscribers_see_the_current_queue_until_it_is_removed() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue head"); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-2")) + .expect("enqueue tail"); + + let bootstrap = manager.subscribe("thread-1"); + assert_eq!(enqueued_ids(&bootstrap.events), vec!["turn-1", "turn-2"]); + + let watcher = manager.subscribe("thread-1"); + let _ = manager.consume(&watcher.subscription_id).expect("drain"); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS), + QueueRemovalOutcome::Removed + ); + // 离开队列的事件带 typed 原因:这条是用户取消。 + let events = manager + .consume(&watcher.subscription_id) + .expect("consume removal") + .events; + assert!( + matches!( + events.as_slice(), + [ThreadEvent::QueueRemoved { client_turn_id, reason, .. }] + if client_turn_id == "turn-1" && *reason == QueueRemovalReason::Cancelled + ), + "{events:?}" + ); + // 队尾那条仍在,队首那条不再对新订阅者可见。 + let bootstrap = manager.subscribe("thread-1"); + assert_eq!(enqueued_ids(&bootstrap.events), vec!["turn-2"]); + } + + /// 中途加入者拿到的是**合并快照**:正在跑那一轮的锚点 + 还没走的队列,顺序就是事件顺序。 + /// + /// 队列搬到宿主之后这件事只能由 live-set bootstrap 顶上:另一个窗口、离开工作台再回来, + /// 看到的状态必须和一直在场的那位一致——在跑的那一轮还在跑(锚点带开口条目身份,条目挂得回去), + /// 排着的那几条还在队,而不是只剩其中一个。 + #[test] + fn late_subscriber_sees_the_running_turn_and_the_pending_queue_together() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue head"); + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-2")) + .expect("enqueue tail"); + + let bootstrap = manager.subscribe("thread-1"); + assert!( + matches!( + bootstrap.events.as_slice(), + [ + ThreadEvent::TurnStarted { .. }, + ThreadEvent::QueueEnqueued { client_turn_id, .. } + ] if client_turn_id == "turn-2" + ), + "中途加入者要先看到在跑的那一轮、再看到还排着的那几条:{:?}", + bootstrap.events + ); + assert_eq!( + bootstrap.events[0].user_item_id(), + Some("direct-codex:turn-1:user"), + "锚点必须带着正在跑那一轮的开口条目身份" + ); + assert_eq!(enqueued_ids(&bootstrap.events), vec!["turn-2"]); + } + + /// 入队幂等:判重范围是「在队 ∪ 正在跑的那一轮」,重复入队不排第二条、不发第二条事件。 + #[test] + fn enqueue_is_idempotent_per_client_turn_id() { + let mut manager = ThreadManager::with_limits(100, 100_000); + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue"), + EnqueueOutcome::Enqueued + ); + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue again"), + EnqueueOutcome::AlreadyKnown + ); + assert_eq!(manager.pending_turn_ids("thread-1"), vec!["turn-1"]); + + // 正在跑的那一轮同样算已知:它的身份已经在占用里,不该再排一条等放行。 + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue while running"), + EnqueueOutcome::AlreadyKnown + ); + assert!(manager.pending_turn_ids("thread-1").is_empty()); + } + + /// 容量只数在队条目:正在跑的那一轮不占等待位。 + #[test] + fn capacity_counts_only_pending_turns() { + let mut manager = ThreadManager::with_limits(100, 100_000); + for index in 0..MAX_PENDING_TURNS { + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn(&format!("turn-{index}"))) + .expect("enqueue"), + EnqueueOutcome::Enqueued + ); + } + assert_eq!( + manager.enqueue_pending_turn("thread-1", pending_turn("turn-overflow")), + Err(EnqueueRejection::QueueFull) + ); + + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-overflow")) + .expect("enqueue after dispatch"), + EnqueueOutcome::Enqueued + ); + } + + /// 入队侧拿来做容量预判的条数与权威判据同源:只数在队条目,认领 / 取消之后立刻跟着变。 + #[test] + fn pending_turn_count_tracks_only_the_queue() { + let mut manager = ThreadManager::with_limits(100, 100_000); + assert_eq!(manager.pending_turn_count("thread-unknown"), 0); + for index in 0..MAX_PENDING_TURNS { + manager + .enqueue_pending_turn("thread-1", pending_turn(&format!("turn-{index}"))) + .expect("enqueue"); + } + assert_eq!(manager.pending_turn_count("thread-1"), MAX_PENDING_TURNS); + assert_eq!( + queue_has_room(manager.pending_turn_count("thread-1")), + Err(EnqueueRejection::QueueFull) + ); + + // 认领队首与取消一条在队消息都立刻反映到这条只读口径上。 + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + assert_eq!( + manager.pending_turn_count("thread-1"), + MAX_PENDING_TURNS - 1 + ); + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS); + assert_eq!( + manager.pending_turn_count("thread-1"), + MAX_PENDING_TURNS - 2 + ); + } + + /// 放行认领的是**队首**,而且开始事件与离开队列事件同批写:中间没有第二个中间态。 + #[test] + fn dispatch_claims_the_head_and_writes_both_events_in_one_batch() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue head"); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-2")) + .expect("enqueue tail"); + let watcher = manager.subscribe("thread-1"); + let _ = manager.consume(&watcher.subscription_id).expect("drain"); + + let claimed = manager + .claim_pending_turn("thread-1", 2_000) + .expect("claim head"); + assert_eq!(claimed.pending.client_turn_id, "turn-1"); + assert_eq!( + direct_codex_user_item_to_prompt(&claimed.pending.user_item), + "消息 turn-1" + ); + assert_eq!(manager.pending_turn_ids("thread-1"), vec!["turn-2"]); + + let events = manager + .consume(&watcher.subscription_id) + .expect("consume dispatch") + .events; + assert!( + matches!( + events.as_slice(), + [ + ThreadEvent::TurnStarted { at, .. }, + ThreadEvent::QueueRemoved { client_turn_id, reason, at: removed_at }, + ] if *at == Some(2_000) + && client_turn_id == "turn-1" + && *reason == QueueRemovalReason::Dispatched + && *removed_at == Some(2_000) + ), + "{events:?}" + ); + + // 已经有未收口的回合:认领不再放行第二条。 + assert!(manager.claim_pending_turn("thread-1", 2_100).is_none()); + assert_eq!(manager.pending_turn_ids("thread-1"), vec!["turn-2"]); + } + + /// 兜底释放是**一个动作**:判据不过时占用与事件都不动,通过时占用解除与兜底终态一起落地。 + #[test] + fn stale_release_validates_and_releases_as_one_action() { + let mut manager = ThreadManager::with_limits(100, 100_000); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue"); + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + let watcher = manager.subscribe("thread-1"); + let _ = manager.consume(&watcher.subscription_id).expect("drain"); + + // 身份对不上:放回去,占用不动、一个字都不写。 + assert_eq!( + manager.release_stale_turn("thread-1", Some("turn-other"), None, FIXED_AT_MS + 10_000), + StaleTurnRelease::AnotherTurnRunning + ); + // 启动窗口内:同样什么都不写。 + assert_eq!( + manager.release_stale_turn("thread-1", None, Some(60_000), FIXED_AT_MS + 1), + StaleTurnRelease::WithinStartupWindow { age_ms: 1 } + ); + assert!(manager.turn_is_active("thread-1")); + assert!( + manager + .consume(&watcher.subscription_id) + .expect("consume rejected releases") + .events + .is_empty(), + "判据不过不许写任何事件" + ); + + // 通过:占用解除与兜底终态在同一个临界区里落地,身份取被释放那一轮自己的 turn_id。 + assert_eq!( + manager.release_stale_turn( + "thread-1", + Some("turn-1"), + Some(60_000), + FIXED_AT_MS + 60_000 + ), + StaleTurnRelease::Released("turn-1".to_string()) + ); + assert!(!manager.turn_is_active("thread-1")); + let events = manager + .consume(&watcher.subscription_id) + .expect("consume released terminal") + .events; + assert_eq!(events.len(), 1, "兜底释放只写一条终态:{events:?}"); + assert!( + matches!( + events[0], + ThreadEvent::TurnCompleted { ref status, .. } if status == "aborted" + ), + "{events:?}" + ); + assert_eq!( + events[0].user_item_id().as_deref(), + Some("direct-codex:turn-1:user") + ); + + // 占用已经解除,再释放就是"没有活动回合"。 + assert_eq!( + manager.release_stale_turn("thread-1", None, None, FIXED_AT_MS + 60_000), + StaleTurnRelease::NoActiveTurn + ); + } + + /// 取消的三种结果分得开:真的移除了、已经被放行了、本来就不在队里。 + #[test] + fn removal_tells_cancelled_dispatched_and_unknown_apart() { + let mut manager = ThreadManager::with_limits(100, 100_000); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS), + QueueRemovalOutcome::NotFound + ); + + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue"); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS), + QueueRemovalOutcome::Removed + ); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS), + QueueRemovalOutcome::NotFound + ); + + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-2")) + .expect("enqueue"); + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-2", FIXED_AT_MS), + QueueRemovalOutcome::AlreadyDispatched + ); + } + + /// 首页快照的变更通知必须跟着快照自己的事实源走:放行(登记占用)、进度内容变化、收口各通知一次, + /// 状态与活动都没变的重复进度不重复通知;入队与取消只改队列,不进快照,不通知。 + /// + /// 计数器是进程级的,因此本用例与仓库既有的 Rust 定向命令一样按 `--test-threads=1` 跑。 + #[test] + fn active_turn_changes_publish_one_notification_per_real_change() { + let thread_id = format!("/tmp/事件项目-{}", Uuid::new_v4()); + let before = crate::agent::direct_active_turns_event_test_count(); + + enqueue_pending_turn(&thread_id, pending_turn("turn-1")).expect("enqueue"); + assert_eq!( + crate::agent::direct_active_turns_event_test_count(), + before, + "入队只是排队,不进快照:不通知" + ); + claim_pending_turn(&thread_id).expect("放行队首"); + assert_eq!( + crate::agent::direct_active_turns_event_test_count(), + before + 1, + "放行必须通知一次" + ); + + assert!(update_active_turn( + &thread_id, + "turn-1", + "streaming", + Some("file-write"), + 3, + 42, + )); + let after_progress = crate::agent::direct_active_turns_event_test_count(); + assert_eq!(after_progress, before + 2, "进度内容变化必须通知一次"); + + assert!(!update_active_turn( + &thread_id, + "turn-1", + "streaming", + Some("file-write"), + 4, + 43, + )); + assert_eq!( + crate::agent::direct_active_turns_event_test_count(), + after_progress, + "状态与活动都没变的重复进度不重复通知" + ); + + complete_turn( + &thread_id, + ThreadEvent::turn_completed("completed".to_string(), 5_000), + ); + assert_eq!( + crate::agent::direct_active_turns_event_test_count(), + after_progress + 1, + "收口必须通知一次" + ); + assert!(list_active_turns() + .expect("list active turns") + .into_iter() + .all(|turn| turn.project_path != thread_id)); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs new file mode 100644 index 000000000..12fc6879c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs @@ -0,0 +1,209 @@ +//! DirectProject 待发消息队列的条目与规则。 +//! +//! 队列的成员与顺序**就是 Thread Manager 的事件列表本身**:一条待发消息在队期间,它的 +//! `queue.enqueued` 事件不可回收;离开队列(取消或放行)时才转成可回收。新订阅者的 bootstrap 因此 +//! 天然看得见当前队列,不需要第二张队列表,也不会有"事件与队列不一致"的窗口。 +//! +//! 这个模块只放三件事:一条待发消息在线上长什么样([`PendingTurn`] 与 +//! [`PendingTurn::enqueued_event`])、容量规则([`MAX_PENDING_TURNS`])、以及"它还在不在队" +//! 这条判据的成员折叠(`ThreadManager::pending_turns`)。 +//! 它不碰锁、不碰 Tauri、不写盘、不重算 prompt:入队检查在命令侧,放行顺序在 Thread Manager。 + +use crate::agent::{ + direct_codex_user_item_id_for_client_turn_id, DirectCodexUserItem, ThreadEvent, +}; + +/// 一个项目最多能同时排队的待发消息条数。 +/// +/// 只数**在队**条目,不算正在跑的那一轮。上限只落在宿主这一处:前端不再自己数,满队由命令返回 +/// typed 入队失败。 +pub(crate) const MAX_PENDING_TURNS: usize = 5; + +/// 一条已经通过入队检查、正在等放行的用户消息。 +/// +/// 它**就是 `queue.enqueued` 的投影**([`PendingTurn::from_event`] / [`PendingTurn::enqueued_event`]): +/// 字段与事件载荷一一对应,宿主不为它另存第二份产物——canonical 形状与 prompt 都在放行时从这条条目 +/// 重投影。在队 = 事件窗口里"有 `queue.enqueued`、没有配对 `queue.removed`",成员与顺序因此只从事件 +/// 折出来,没有"事件说在队、队列表说不在"的中间态。 +#[derive(Clone, Debug)] +pub(crate) struct PendingTurn { + /// 这条消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 + pub(crate) client_turn_id: String, + /// canonical 用户条目:事件与界面 chip 都读它,Rust 不渲染展示形状。 + pub(crate) user_item: DirectCodexUserItem, + pub(crate) creation_type: Option, + /// 入队那一刻的宿主毫秒钟。 + pub(crate) at: u64, +} + +impl PendingTurn { + pub(crate) fn new( + client_turn_id: String, + user_item: DirectCodexUserItem, + creation_type: Option, + at: u64, + ) -> Self { + Self { + client_turn_id, + user_item, + creation_type, + at, + } + } + + /// 从 `queue.enqueued` 事件折出这条待发消息;其它事件返回 `None`。 + pub(crate) fn from_event(event: &ThreadEvent) -> Option { + let ThreadEvent::QueueEnqueued { + client_turn_id, + user_item, + creation_type, + at, + } = event + else { + return None; + }; + Some(Self { + client_turn_id: client_turn_id.clone(), + user_item: user_item.clone(), + creation_type: creation_type.clone(), + at: *at, + }) + } + + /// 入队事件的投影:带 canonical 用户条目与 `creationType`,**不带 prompt**(prompt 是入队检查的 + /// 产物,放行时由条目重投影,不是要下发的展示形状),也不带埋点身份(成绩由宿主自己结算)。 + pub(crate) fn enqueued_event(&self) -> ThreadEvent { + ThreadEvent::queue_enqueued( + self.client_turn_id.clone(), + self.user_item.clone(), + self.creation_type.clone(), + self.at, + ) + } + + /// 这条待发消息在历史里的用户条目 id:前端用它把 chip 与回合边界对上。 + pub(crate) fn user_item_id(&self) -> Option { + direct_codex_user_item_id_for_client_turn_id(&self.client_turn_id) + } +} + +/// 入队的结果。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum EnqueueOutcome { + /// 这次真的排进队尾了。 + Enqueued, + /// 同一 `clientTurnId` 已经在队(或正在跑):按幂等返回成功,不排第二条、不发事件。 + AlreadyKnown, +} + +/// 入队被检查挡下来的原因。满队之外的原因由入队侧自己的 typed 错误表达。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum EnqueueRejection { + /// 在队条目已达 [`MAX_PENDING_TURNS`]。 + QueueFull, +} + +/// 队列还有没有位置。`pending_count` 只数在队条目。 +pub(crate) fn queue_has_room(pending_count: usize) -> Result<(), EnqueueRejection> { + if pending_count >= MAX_PENDING_TURNS { + return Err(EnqueueRejection::QueueFull); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn user_item(text: &str, id: &str) -> DirectCodexUserItem { + serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": text}], + "id": id, + })) + .expect("canonical user item") + } + + fn pending(client_turn_id: &str, creation_type: Option<&str>) -> PendingTurn { + PendingTurn::new( + client_turn_id.to_string(), + user_item("生成一个游戏", "direct-codex:turn-1:user"), + creation_type.map(str::to_string), + 1_700_000_000_123, + ) + } + + /// 入队事件只带 canonical 用户条目与 `creationType`:prompt 是宿主的入队检查产物, + /// 不许顺着事件下发。 + #[test] + fn enqueued_event_carries_the_canonical_item_and_no_prompt() { + let event = pending("turn-1", Some("web-game")).enqueued_event(); + let value = serde_json::to_value(&event).expect("serialize queue.enqueued"); + assert_eq!( + value, + json!({ + "type": "queue.enqueued", + "clientTurnId": "turn-1", + "userItem": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "生成一个游戏"}], + "id": "direct-codex:turn-1:user", + }, + "creationType": "web-game", + "at": 1_700_000_000_123u64, + }) + ); + assert!(value.get("prompt").is_none(), "{value}"); + + // 没有创建类型时不写字段,也不补 `null`。 + let bare = serde_json::to_value(pending("turn-2", None).enqueued_event()) + .expect("serialize queue.enqueued without creation type"); + assert!(bare.get("creationType").is_none(), "{bare}"); + + // 事件读得出自己的待发消息身份。 + assert_eq!(event.queue_client_turn_id(), Some("turn-1")); + assert_eq!(event.queue_removal_reason(), None); + } + + /// 条目就是事件投影:由自己的入队事件折回来的条目与原件逐字段相同。 + #[test] + fn pending_turn_round_trips_through_its_enqueued_event() { + for turn in [pending("turn-1", Some("web-game")), pending("turn-2", None)] { + let restored = + PendingTurn::from_event(&turn.enqueued_event()).expect("queue.enqueued projects"); + assert_eq!(restored.client_turn_id, turn.client_turn_id); + assert_eq!(restored.creation_type, turn.creation_type); + assert_eq!(restored.at, turn.at); + assert_eq!( + serde_json::to_value(&restored.user_item).expect("serialize user item"), + serde_json::to_value(&turn.user_item).expect("serialize user item") + ); + assert!(PendingTurn::from_event(&ThreadEvent::turn_started(1)).is_none()); + } + } + + /// 用户条目身份由 `clientTurnId` 派生,与落盘 / 下发用的是同一个函数。 + #[test] + fn user_item_id_derives_from_the_client_turn_id() { + assert_eq!( + pending("turn-1", None).user_item_id().as_deref(), + Some("direct-codex:turn-1:user") + ); + assert_eq!(pending(" ", None).user_item_id(), None); + } + + /// 容量只数在队条目,5 条封口;在跑的那一轮不算进去。 + #[test] + fn capacity_closes_at_five_pending_turns() { + for count in 0..MAX_PENDING_TURNS { + assert_eq!(queue_has_room(count), Ok(()), "{count} 条时仍该有位置"); + } + assert_eq!( + queue_has_room(MAX_PENDING_TURNS), + Err(EnqueueRejection::QueueFull) + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs similarity index 79% rename from apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs rename to apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs index 952dd3a24..f5d328482 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs @@ -13,6 +13,7 @@ use crate::agent::redact_secret_tokens; use crate::agent::sanitize_error_context; +use crate::agent::DirectCodexUserItem; use crate::agent::DirectTurnFailureKind; use crate::redact_absolute_path_tokens; use serde::{Deserialize, Serialize}; @@ -21,17 +22,17 @@ use std::path::Path; use ts_rs::TS; /// 正文(消息 / 思考)上限。 -const DIRECT_THREAD_TEXT_MAX_CHARS: usize = 8_000; +const THREAD_TEXT_MAX_CHARS: usize = 8_000; /// 工具明细(命令 / 参数 / 输出)上限。 -const DIRECT_THREAD_DETAIL_MAX_CHARS: usize = 4_000; +const THREAD_DETAIL_MAX_CHARS: usize = 4_000; /// 单条变更路径上限。 -const DIRECT_THREAD_PATH_MAX_CHARS: usize = 300; +const THREAD_PATH_MAX_CHARS: usize = 300; /// 一条文件变更。 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectThreadFileChange { +pub(crate) struct ThreadFileChange { pub(crate) path: String, /// `add` | `update` | `delete` pub(crate) kind: String, @@ -40,14 +41,14 @@ pub(crate) struct DirectThreadFileChange { /// 聊天视图的输入条目:一条 Codex 原始条目的脱敏投影。 /// /// `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。 -/// 未识别的类型走 [`DirectThreadItem::Other`],Rust 不替前端决定它是否可见。 +/// 未识别的类型走 [`ThreadItem::Other`],Rust 不替前端决定它是否可见。 /// /// 条目上的 `at` 是只用于显示的毫秒时间戳:ts-rs 默认把 `u64` 映射成 `bigint`, /// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(tag = "itemType", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectThreadItem { +pub(crate) enum ThreadItem { #[serde(rename = "message")] Message { /// 归一身份:全链路只有这一个 id。 @@ -66,7 +67,7 @@ pub(crate) enum DirectThreadItem { at: u64, }, /// 原始 response item 的工具调用:参数在 `arguments`,输出在后续的 - /// [`DirectThreadItem::FunctionCallOutput`](两者共用归一身份)。 + /// [`ThreadItem::FunctionCallOutput`](两者共用归一身份)。 #[serde(rename = "function_call")] FunctionCall { item_id: String, @@ -100,7 +101,7 @@ pub(crate) enum DirectThreadItem { #[serde(rename = "fileChange")] FileChange { item_id: String, - changes: Vec, + changes: Vec, #[ts(as = "f64")] at: u64, }, @@ -142,7 +143,7 @@ pub(crate) enum DirectThreadItem { }, } -impl DirectThreadItem { +impl ThreadItem { /// 归一身份:Thread Manager 用它登记与释放未完成条目,前端用它合并同一张卡片。 pub(crate) fn item_id(&self) -> &str { match self { @@ -181,7 +182,7 @@ impl DirectThreadItem { #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectThreadDeltaKind { +pub(crate) enum ThreadDeltaKind { /// assistant 正文。 Message, /// 思考正文。 @@ -192,7 +193,7 @@ pub(crate) enum DirectThreadDeltaKind { #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectThreadRequestKind { +pub(crate) enum ThreadRequestKind { #[serde(rename = "approval.requested")] ApprovalRequested, #[serde(rename = "ask.requested")] @@ -201,7 +202,7 @@ pub(crate) enum DirectThreadRequestKind { RequestResolved, } -impl DirectThreadRequestKind { +impl ThreadRequestKind { /// 未解决的请求要留在 bootstrap 里,直到出现对应的解决事件。 pub(crate) fn is_request(&self) -> bool { matches!(self, Self::ApprovalRequested | Self::AskRequested) @@ -212,6 +213,33 @@ impl DirectThreadRequestKind { } } +/// 一条待发消息离开队列的原因。 +/// +/// typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经离开队列并成为回合 +/// (同一临界区里另有 `turn.started`)。界面按它分流,不解析字符串。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum QueueRemovalReason { + /// 用户取消了这条待发消息。 + Cancelled, + /// 放行:这条待发消息已经离开队列,成为正在跑的那一轮。 + Dispatched, +} + +/// 取消一条待发消息的结果。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum QueueRemovalOutcome { + /// 已从队列移除。 + Removed, + /// 这条消息已经被放行(正在跑的那一轮就是它),不能按待发消息取消。 + AlreadyDispatched, + /// 队列里没有这个身份,也没有在跑的一轮是它。 + NotFound, +} + /// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 /// /// `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的 @@ -249,7 +277,7 @@ impl DirectTurnFailure { /// `item.at` 只说明"这条条目什么时候被看到",工具计时不得拿它当开始或完成边界。 /// 条目阶段优先用通知层的毫秒字段(`startedAtMs` / `completedAtMs`),缺失才用宿主钟; /// 回合阶段没有可用的毫秒上游字段(Turn 只有秒级 `startedAt` / `completedAt`),一律用宿主 -/// 在该阶段取的毫秒钟——见 `direct_thread_turn_completed_at_ms` 的说明。 +/// 在该阶段取的毫秒钟——见 `thread_turn_completed_at_ms` 的说明。 /// `at` 在事件进入 Thread Manager 时就固定:重放(bootstrap / consume)必须沿用原值, /// 不能在前端收到或重放时重新取当前时间。 /// @@ -259,13 +287,13 @@ impl DirectTurnFailure { /// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 /// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 /// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectThreadEvent { +pub(crate) enum ThreadEvent { #[serde(rename = "turn.started")] TurnStarted { - /// 本轮开始的阶段时间(毫秒):**接单**那一刻的宿主毫秒钟(逻辑回合的起点,不是 + /// 本轮开始的阶段时间(毫秒):**放行**那一刻的宿主毫秒钟(逻辑回合的起点,不是 /// `turn/start` 的时刻)。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional, as = "Option")] @@ -295,7 +323,7 @@ pub(crate) enum DirectThreadEvent { }, #[serde(rename = "item.started")] ItemStarted { - item: DirectThreadItem, + item: ThreadItem, /// 条目开始执行的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional, as = "Option")] @@ -303,7 +331,7 @@ pub(crate) enum DirectThreadEvent { }, #[serde(rename = "item.completed")] ItemCompleted { - item: DirectThreadItem, + item: ThreadItem, /// 条目结束的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional, as = "Option")] @@ -312,18 +340,47 @@ pub(crate) enum DirectThreadEvent { #[serde(rename = "item.delta")] ItemDelta { item_id: String, - kind: DirectThreadDeltaKind, + kind: ThreadDeltaKind, delta: String, }, #[serde(rename = "request")] Request { - kind: DirectThreadRequestKind, + kind: ThreadRequestKind, #[serde(default)] request_id: Option, }, + /// 待发消息入队:数组顺序就是队首到队尾的顺序。 + /// + /// 这条事件在条目仍在队期间**不可回收**,离开队列(取消或放行)时才转成可回收——新订阅者 + /// 靠这一点在 bootstrap 里看到当前队列,`is_bootstrap_event` 不需要为它加特例。 + /// + /// 事件就是这条待发消息的**全部**事实:宿主不为它另存产物,prompt 与 canonical 形状都在放行时 + /// 从这条条目重投影。 + #[serde(rename = "queue.enqueued")] + QueueEnqueued { + /// 这条待发消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 + client_turn_id: String, + /// canonical 用户条目:前端据此派生 chip 文案,Rust 不渲染展示形状。 + user_item: DirectCodexUserItem, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + creation_type: Option, + /// 入队那一刻的宿主毫秒钟。 + #[ts(as = "f64")] + at: u64, + }, + /// 待发消息离开队列:`reason` 是取消还是放行。 + #[serde(rename = "queue.removed")] + QueueRemoved { + client_turn_id: String, + reason: QueueRemovalReason, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, } -impl DirectThreadEvent { +impl ThreadEvent { pub(crate) fn turn_started(at: u64) -> Self { Self::TurnStarted { at: Some(at), @@ -397,15 +454,15 @@ impl DirectThreadEvent { } } - pub(crate) fn item_started(item: DirectThreadItem, at: u64) -> Self { + pub(crate) fn item_started(item: ThreadItem, at: u64) -> Self { Self::ItemStarted { item, at: Some(at) } } - pub(crate) fn item_completed(item: DirectThreadItem, at: u64) -> Self { + pub(crate) fn item_completed(item: ThreadItem, at: u64) -> Self { Self::ItemCompleted { item, at: Some(at) } } - pub(crate) fn item_delta(item_id: String, kind: DirectThreadDeltaKind, delta: String) -> Self { + pub(crate) fn item_delta(item_id: String, kind: ThreadDeltaKind, delta: String) -> Self { Self::ItemDelta { item_id, kind, @@ -413,10 +470,55 @@ impl DirectThreadEvent { } } - pub(crate) fn request(kind: DirectThreadRequestKind, request_id: Option) -> Self { + pub(crate) fn request(kind: ThreadRequestKind, request_id: Option) -> Self { Self::Request { kind, request_id } } + /// 待发消息入队事件。 + pub(crate) fn queue_enqueued( + client_turn_id: String, + user_item: DirectCodexUserItem, + creation_type: Option, + at: u64, + ) -> Self { + Self::QueueEnqueued { + client_turn_id, + user_item, + creation_type, + at, + } + } + + /// 待发消息离开队列事件。 + pub(crate) fn queue_removed( + client_turn_id: String, + reason: QueueRemovalReason, + at: u64, + ) -> Self { + Self::QueueRemoved { + client_turn_id, + reason, + at: Some(at), + } + } + + /// 这条事件属于哪条待发消息:只有队列事件有。 + pub(crate) fn queue_client_turn_id(&self) -> Option<&str> { + match self { + Self::QueueEnqueued { client_turn_id, .. } + | Self::QueueRemoved { client_turn_id, .. } => Some(client_turn_id), + _ => None, + } + } + + /// 待发消息离开队列的原因:只有 `queue.removed` 有。 + pub(crate) fn queue_removal_reason(&self) -> Option { + match self { + Self::QueueRemoved { reason, .. } => Some(*reason), + _ => None, + } + } + /// 事件级阶段时间(毫秒):只有四种生命周期事件有,其余事件返回 `None`。 /// /// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。 @@ -427,6 +529,8 @@ impl DirectThreadEvent { | Self::TurnCompleted { at, .. } | Self::ItemStarted { at, .. } | Self::ItemCompleted { at, .. } => *at, + Self::QueueEnqueued { at, .. } => Some(*at), + Self::QueueRemoved { at, .. } => *at, Self::ItemDelta { .. } | Self::Request { .. } => None, } } @@ -448,7 +552,7 @@ impl DirectThreadEvent { } } - pub(crate) fn request_kind(&self) -> Option { + pub(crate) fn request_kind(&self) -> Option { match self { Self::Request { kind, .. } => Some(*kind), _ => None, @@ -456,31 +560,31 @@ impl DirectThreadEvent { } } -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectThreadSubscriptionBootstrap { +pub(crate) struct SubscriptionBootstrap { pub(crate) subscription_id: String, /// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 #[serde(default)] pub(crate) last_completed_item_id: Option, /// 该 subscriber 此刻应当处理的运行态事件(游标已经在队尾)。 - pub(crate) events: Vec, + pub(crate) events: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct ConsumeResult { + pub(crate) events: Vec, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectThreadConsumeResult { - pub(crate) events: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectThreadHistorySlice { +pub(crate) struct HistorySlice { /// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 - pub(crate) items: Vec, + pub(crate) items: Vec, pub(crate) has_more: bool, /// 本次切片的原始 item id 锚点:无论切片里有没有可显示条目,分页都靠它向前。 #[serde(default)] @@ -497,10 +601,7 @@ fn bounded(value: &str, max_chars: usize) -> String { } fn detail_text(root: &Path, value: &str) -> String { - bounded( - &sanitize_detail_text(root, value), - DIRECT_THREAD_DETAIL_MAX_CHARS, - ) + bounded(&sanitize_detail_text(root, value), THREAD_DETAIL_MAX_CHARS) } /// 原始值转文本:字符串原样,其它 JSON 值序列化(调用方随后脱敏)。 @@ -536,16 +637,13 @@ fn item_text(root: &Path, item: &Value) -> Option { .filter(|text| !text.trim().is_empty())?; Some(bounded( &sanitize_detail_text(root, &raw), - DIRECT_THREAD_TEXT_MAX_CHARS, + THREAD_TEXT_MAX_CHARS, )) } /// 流式正文与完成态条目使用同一套脱敏和字符预算,避免增量文本绕过历史投影的安全边界。 -pub(crate) fn direct_thread_delta_text(root: &Path, value: &str) -> String { - bounded( - &sanitize_detail_text(root, value), - DIRECT_THREAD_TEXT_MAX_CHARS, - ) +pub(crate) fn thread_delta_text(root: &Path, value: &str) -> String { + bounded(&sanitize_detail_text(root, value), THREAD_TEXT_MAX_CHARS) } fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { @@ -581,7 +679,7 @@ fn json_ms(container: &Value, key: &str) -> Option { /// 条目自带时用条目里的同名毫秒字段(`direct_tool_calls` 读的是同一处)。完成事件即使同时 /// 带着开始字段也只取**完成**时间;两者都没有、但 `durationMs` 有可靠起点时按 /// 起点 + 时长派生结束。都没有就用宿主处理该事件的钟——原生缺阶段时间时这是唯一诚实的值。 -pub(crate) fn direct_thread_item_event_at_ms( +pub(crate) fn thread_item_event_at_ms( params: &Value, item: &Value, completed: bool, @@ -613,7 +711,7 @@ pub(crate) fn direct_thread_item_event_at_ms( /// /// `high_precision_started_at_ms` 是本轮开始时宿主记下的那个毫秒起点(即 `turn.started` /// 事件写入的同一个值),不是从上游秒字段换算出来的,`None` 表示起点也不可证明。 -pub(crate) fn direct_thread_turn_completed_at_ms( +pub(crate) fn thread_turn_completed_at_ms( turn: &Value, high_precision_started_at_ms: Option, observed_at_ms: u64, @@ -625,7 +723,7 @@ pub(crate) fn direct_thread_turn_completed_at_ms( } /// 归一身份:工具条目用工具调用 id,其它条目用自己的 `id`;只产出这一个值。 -pub(crate) fn direct_thread_item_identity(item: &Value) -> Option { +pub(crate) fn thread_item_identity(item: &Value) -> Option { let call_id = item .get("call_id") .or_else(|| item.get("callId")) @@ -642,7 +740,7 @@ pub(crate) fn direct_thread_item_identity(item: &Value) -> Option { call_id.or(id) } -fn item_changes(root: &Path, item: &Value) -> Vec { +fn item_changes(root: &Path, item: &Value) -> Vec { item.get("changes") .and_then(Value::as_array) .map(|changes| { @@ -654,11 +752,8 @@ fn item_changes(root: &Path, item: &Value) -> Vec { .and_then(Value::as_str) .map(str::trim) .filter(|path| !path.is_empty())?; - Some(DirectThreadFileChange { - path: bounded( - &sanitize_detail_text(root, path), - DIRECT_THREAD_PATH_MAX_CHARS, - ), + Some(ThreadFileChange { + path: bounded(&sanitize_detail_text(root, path), THREAD_PATH_MAX_CHARS), kind: change .get("kind") .and_then(Value::as_str) @@ -680,15 +775,15 @@ fn field_text(root: &Path, item: &Value, key: &str) -> Option { /// 把一条 Codex 原始条目投影成线上条目;拿不到身份或类型时返回 `None`。 /// /// `observed_at_ms` 只在条目自带时间缺失时兜底(运行态用当前时间,历史用文件记录时间)。 -pub(crate) fn direct_thread_item_from_value( +pub(crate) fn thread_item_from_value( root: &Path, item: &Value, observed_at_ms: u64, -) -> Option { +) -> Option { if !item.is_object() { return None; } - let item_id = direct_thread_item_identity(item)?; + let item_id = thread_item_identity(item)?; let item_type = item .get("type") .and_then(Value::as_str) @@ -704,7 +799,7 @@ pub(crate) fn direct_thread_item_from_value( .map(str::to_string); Some(match item_type { - "message" | "agentMessage" | "userMessage" => DirectThreadItem::Message { + "message" | "agentMessage" | "userMessage" => ThreadItem::Message { item_id, role: role.unwrap_or_else(|| { if item_type == "userMessage" { @@ -716,12 +811,12 @@ pub(crate) fn direct_thread_item_from_value( text: text?, at, }, - "reasoning" => DirectThreadItem::Reasoning { + "reasoning" => ThreadItem::Reasoning { item_id, text: text?, at, }, - "function_call" => DirectThreadItem::FunctionCall { + "function_call" => ThreadItem::FunctionCall { item_id, name: item .get("name") @@ -731,12 +826,12 @@ pub(crate) fn direct_thread_item_from_value( arguments: field_text(root, item, "arguments").unwrap_or_default(), at, }, - "function_call_output" => DirectThreadItem::FunctionCallOutput { + "function_call_output" => ThreadItem::FunctionCallOutput { item_id, output: field_text(root, item, "output").unwrap_or_default(), at, }, - "commandExecution" => DirectThreadItem::CommandExecution { + "commandExecution" => ThreadItem::CommandExecution { item_id, command: item .get("command") @@ -755,12 +850,12 @@ pub(crate) fn direct_thread_item_from_value( exit_code: item.get("exitCode").and_then(Value::as_i64), at, }, - "fileChange" => DirectThreadItem::FileChange { + "fileChange" => ThreadItem::FileChange { item_id, changes: item_changes(root, item), at, }, - "mcpToolCall" => DirectThreadItem::McpToolCall { + "mcpToolCall" => ThreadItem::McpToolCall { item_id, tool: item .get("tool") @@ -779,7 +874,7 @@ pub(crate) fn direct_thread_item_from_value( .map(str::to_string), at, }, - "webSearch" => DirectThreadItem::WebSearch { + "webSearch" => ThreadItem::WebSearch { item_id, query: field_text(root, item, "query").or_else(|| { item.get("action") @@ -790,8 +885,8 @@ pub(crate) fn direct_thread_item_from_value( output: field_text(root, item, "output"), at, }, - "contextCompaction" => DirectThreadItem::ContextCompaction { item_id, at }, - other => DirectThreadItem::Other { + "contextCompaction" => ThreadItem::ContextCompaction { item_id, at }, + other => ThreadItem::Other { item_id, raw_type: other.to_string(), at, @@ -802,14 +897,14 @@ pub(crate) fn direct_thread_item_from_value( /// 历史切片投影:保持文件顺序,不做任何合并(同一调用的调用与输出是两条条目)。 /// /// `timestamp_of` 是文件记录时间,仅在条目自带时间缺失时兜底。 -pub(crate) fn direct_thread_items_from_history( +pub(crate) fn thread_items_from_history( root: &Path, items: &[Value], timestamp_of: impl Fn(&Value) -> u64, -) -> Vec { +) -> Vec { items .iter() - .filter_map(|item| direct_thread_item_from_value(root, item, timestamp_of(item))) + .filter_map(|item| thread_item_from_value(root, item, timestamp_of(item))) .collect() } @@ -944,7 +1039,7 @@ mod tests { #[test] fn message_item_carries_role_text_and_turn() { - let item = direct_thread_item_from_value( + let item = thread_item_from_value( root(), &json!({ "id": "direct-codex:turn-1:user", @@ -957,7 +1052,7 @@ mod tests { .expect("user item"); assert_eq!( item, - DirectThreadItem::Message { + ThreadItem::Message { item_id: "direct-codex:turn-1:user".to_string(), role: "user".to_string(), text: "做一个拼图游戏".to_string(), @@ -968,7 +1063,7 @@ mod tests { #[test] fn app_server_agent_message_defaults_to_assistant_role() { - let item = direct_thread_item_from_value( + let item = thread_item_from_value( root(), &json!({"id": "msg-1", "type": "agentMessage", "text": "已执行"}), 1000, @@ -976,13 +1071,13 @@ mod tests { .expect("agent message"); assert!(matches!( item, - DirectThreadItem::Message { role, .. } if role == "assistant" + ThreadItem::Message { role, .. } if role == "assistant" )); } #[test] fn tool_item_identity_is_normalized_to_one_id() { - let item = direct_thread_item_from_value( + let item = thread_item_from_value( root(), &json!({ "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", @@ -999,14 +1094,14 @@ mod tests { assert_eq!(item.item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); assert!(matches!( item, - DirectThreadItem::FunctionCall { name, arguments, .. } + ThreadItem::FunctionCall { name, arguments, .. } if name == "exec_command" && arguments == "{\"cmd\": \"ls\"}" )); } #[test] fn command_execution_keeps_raw_status_and_exit_code() { - let item = direct_thread_item_from_value( + let item = thread_item_from_value( root(), &json!({ "id": "call-1", @@ -1021,7 +1116,7 @@ mod tests { .expect("command item"); assert!(matches!( item, - DirectThreadItem::CommandExecution { + ThreadItem::CommandExecution { command, output: Some(output), status: Some(status), @@ -1033,7 +1128,7 @@ mod tests { #[test] fn secrets_and_absolute_paths_are_not_leaked() { - let item = direct_thread_item_from_value( + let item = thread_item_from_value( root(), &json!({ "id": "msg-1", @@ -1044,7 +1139,7 @@ mod tests { 0, ) .expect("assistant item"); - let DirectThreadItem::Message { text, .. } = item else { + let ThreadItem::Message { text, .. } = item else { panic!("message item"); }; assert!( @@ -1071,15 +1166,12 @@ mod tests { "output": "assets\ngame\n", }), ]; - let projected = direct_thread_items_from_history(root(), &items, |_| 0); + let projected = thread_items_from_history(root(), &items, |_| 0); assert_eq!(projected.len(), 2, "搬运层不得替前端做合并"); - assert!(matches!( - projected[0], - DirectThreadItem::FunctionCall { .. } - )); + assert!(matches!(projected[0], ThreadItem::FunctionCall { .. })); assert!(matches!( projected[1], - DirectThreadItem::FunctionCallOutput { .. } + ThreadItem::FunctionCallOutput { .. } )); // 调用与输出共享同一个归一身份,前端才能把它们并成一张卡片。 assert_eq!(projected[0].item_id(), projected[1].item_id()); @@ -1088,7 +1180,7 @@ mod tests { #[test] fn unknown_item_types_are_passed_through_without_body() { - let item = direct_thread_item_from_value( + let item = thread_item_from_value( root(), &json!({"id": "plan-1", "type": "plan", "text": "内部计划"}), 0, @@ -1098,7 +1190,7 @@ mod tests { item, // TODO(direct-thread): 未知类型目前只带类型与身份,前端投影会丢弃它。 // 哪些类型要显示属于前端可见性决策,需要时改前端,不要在这里加白名单。 - DirectThreadItem::Other { ref raw_type, .. } if raw_type == "plan" + ThreadItem::Other { ref raw_type, .. } if raw_type == "plan" )); } @@ -1115,11 +1207,11 @@ mod tests { "startedAtMs": 1_000u64, }, }); - let item = direct_thread_item_from_value(root(), ¶ms["item"], 7_777).expect("item"); + let item = thread_item_from_value(root(), ¶ms["item"], 7_777).expect("item"); // 条目展示时间不受事件级时间影响,仍按条目自己的字段推导。 assert_eq!(item.at(), 1_000); assert_eq!( - direct_thread_item_event_at_ms(¶ms, ¶ms["item"], true, 7_777), + thread_item_event_at_ms(¶ms, ¶ms["item"], true, 7_777), 2_000 ); } @@ -1138,7 +1230,7 @@ mod tests { }, }); assert_eq!( - direct_thread_item_event_at_ms(¶ms, ¶ms["item"], false, 9_999), + thread_item_event_at_ms(¶ms, ¶ms["item"], false, 9_999), 1_700_000_000_123 ); } @@ -1153,18 +1245,18 @@ mod tests { }, }); assert_eq!( - direct_thread_item_event_at_ms(&nested, &nested["item"], false, 9_999), + thread_item_event_at_ms(&nested, &nested["item"], false, 9_999), 1_700_000_000_500 ); // 原生没有任何阶段时间:用宿主处理这条事件的钟,不编造。 let bare = json!({"item": {"id": "call-1", "type": "commandExecution"}}); assert_eq!( - direct_thread_item_event_at_ms(&bare, &bare["item"], false, 9_999), + thread_item_event_at_ms(&bare, &bare["item"], false, 9_999), 9_999 ); assert_eq!( - direct_thread_item_event_at_ms(&bare, &bare["item"], true, 9_999), + thread_item_event_at_ms(&bare, &bare["item"], true, 9_999), 9_999 ); } @@ -1179,7 +1271,7 @@ mod tests { "item": {"id": "call-1", "type": "commandExecution"}, }); assert_eq!( - direct_thread_item_event_at_ms(¶ms, ¶ms["item"], true, 9_999), + thread_item_event_at_ms(¶ms, ¶ms["item"], true, 9_999), 2_000 ); @@ -1193,7 +1285,7 @@ mod tests { }, }); assert_eq!( - direct_thread_item_event_at_ms(&nested, &nested["item"], true, 9_999), + thread_item_event_at_ms(&nested, &nested["item"], true, 9_999), 2_500 ); } @@ -1204,7 +1296,7 @@ mod tests { "item": {"id": "call-1", "type": "commandExecution", "startedAtMs": 1_000u64, "durationMs": 250u64}, }); assert_eq!( - direct_thread_item_event_at_ms(&with_start, &with_start["item"], true, 9_999), + thread_item_event_at_ms(&with_start, &with_start["item"], true, 9_999), 1_250 ); @@ -1213,7 +1305,7 @@ mod tests { "item": {"id": "call-1", "type": "commandExecution", "durationMs": 250u64}, }); assert_eq!( - direct_thread_item_event_at_ms(&duration_only, &duration_only["item"], true, 9_999), + thread_item_event_at_ms(&duration_only, &duration_only["item"], true, 9_999), 9_999 ); } @@ -1230,12 +1322,12 @@ mod tests { "completedAt": 1_700_000_042i64, }); assert_eq!( - direct_thread_turn_completed_at_ms(&seconds_only, Some(1_700_000_000_500), 9_999), + thread_turn_completed_at_ms(&seconds_only, Some(1_700_000_000_500), 9_999), 9_999, "没有 durationMs 时用宿主钟,不换算秒字段" ); assert_eq!( - direct_thread_turn_completed_at_ms(&seconds_only, None, 9_999), + thread_turn_completed_at_ms(&seconds_only, None, 9_999), 9_999 ); } @@ -1251,18 +1343,18 @@ mod tests { }); // 高精度起点(宿主在本轮开始时记下的毫秒值)+ 上游 durationMs:结束严格晚于起点。 assert_eq!( - direct_thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), + thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), 1_700_000_043_000 ); // 起点不可证明时不派生。 assert_eq!( - direct_thread_turn_completed_at_ms(&with_duration, None, 9_999), + thread_turn_completed_at_ms(&with_duration, None, 9_999), 9_999 ); // 时长为 0 同样按缺失处理。 let zero_duration = json!({"durationMs": 0u64}); assert_eq!( - direct_thread_turn_completed_at_ms(&zero_duration, Some(1_000), 9_999), + thread_turn_completed_at_ms(&zero_duration, Some(1_000), 9_999), 9_999 ); } @@ -1271,28 +1363,26 @@ mod tests { /// 反序列化仍成立,且不会序列化出 `at: null`。 #[test] fn lifecycle_events_serialize_event_level_at_as_optional_number() { - let started = serde_json::to_value(DirectThreadEvent::turn_started(1_700_000_000_123)) + let started = serde_json::to_value(ThreadEvent::turn_started(1_700_000_000_123)) .expect("serialize turn.started"); assert_eq!( started, json!({"type": "turn.started", "at": 1_700_000_000_123u64}) ); assert_eq!( - serde_json::from_value::(started).expect("round trip"), - DirectThreadEvent::turn_started(1_700_000_000_123) + serde_json::from_value::(started).expect("round trip"), + ThreadEvent::turn_started(1_700_000_000_123) ); - let completed = serde_json::to_value(DirectThreadEvent::turn_completed( - "completed".to_string(), - 2_000, - )) - .expect("serialize turn.completed"); + let completed = + serde_json::to_value(ThreadEvent::turn_completed("completed".to_string(), 2_000)) + .expect("serialize turn.completed"); assert_eq!( completed, json!({"type": "turn.completed", "status": "completed", "at": 2_000u64}) ); - let item = DirectThreadItem::CommandExecution { + let item = ThreadItem::CommandExecution { item_id: "call-1".to_string(), command: "ls".to_string(), output: None, @@ -1300,22 +1390,21 @@ mod tests { exit_code: None, at: 1_500, }; - let item_started = - serde_json::to_value(DirectThreadEvent::item_started(item.clone(), 1_000)) - .expect("serialize item.started"); + let item_started = serde_json::to_value(ThreadEvent::item_started(item.clone(), 1_000)) + .expect("serialize item.started"); assert_eq!(item_started["at"], json!(1_000u64)); // 事件级 `at` 不动条目自己的展示时间。 assert_eq!(item_started["item"]["at"], json!(1_500u64)); - let item_completed = serde_json::to_value(DirectThreadEvent::item_completed(item, 2_000)) + let item_completed = serde_json::to_value(ThreadEvent::item_completed(item, 2_000)) .expect("serialize item.completed"); assert_eq!(item_completed["at"], json!(2_000u64)); // 历史 / 夹具里的旧事件没有 `at`:反序列化成 `None`,回写时不补 `null`。 - let legacy: DirectThreadEvent = serde_json::from_value(json!({"type": "turn.started"})) + let legacy: ThreadEvent = serde_json::from_value(json!({"type": "turn.started"})) .expect("legacy turn.started without at"); assert_eq!( legacy, - DirectThreadEvent::TurnStarted { + ThreadEvent::TurnStarted { at: None, user_item_id: None, } @@ -1327,8 +1416,8 @@ mod tests { json!({"type": "turn.started"}) ); assert_eq!( - serde_json::to_value(DirectThreadEvent::request( - DirectThreadRequestKind::RequestResolved, + serde_json::to_value(ThreadEvent::request( + ThreadRequestKind::RequestResolved, None, )) .expect("serialize request"), @@ -1340,8 +1429,8 @@ mod tests { /// 缺省不写字段,旧事件反序列化仍是 `None`,空白身份按缺失处理(不猜)。 #[test] fn lifecycle_events_carry_optional_opener_user_item_id() { - let started = DirectThreadEvent::turn_started(1_000) - .with_user_item_id(Some("direct-codex:turn-1:user")); + let started = + ThreadEvent::turn_started(1_000).with_user_item_id(Some("direct-codex:turn-1:user")); assert_eq!(started.user_item_id(), Some("direct-codex:turn-1:user")); assert_eq!( serde_json::to_value(&started).expect("serialize turn.started"), @@ -1352,14 +1441,14 @@ mod tests { }) ); assert_eq!( - serde_json::from_value::( + serde_json::from_value::( serde_json::to_value(&started).expect("serialize") ) .expect("round trip"), started ); - let completed = DirectThreadEvent::turn_completed("interrupted".to_string(), 2_000) + let completed = ThreadEvent::turn_completed("interrupted".to_string(), 2_000) .with_user_item_id(Some("direct-codex:turn-1:user")); assert_eq!(completed.user_item_id(), Some("direct-codex:turn-1:user")); assert_eq!( @@ -1376,7 +1465,7 @@ mod tests { // 空白 / 空串按缺失处理:不能把 "" 当成一条用户条目的身份发下去。 for empty in ["", " "] { - let event = DirectThreadEvent::turn_started(1_000).with_user_item_id(Some(empty)); + let event = ThreadEvent::turn_started(1_000).with_user_item_id(Some(empty)); assert_eq!(event.user_item_id(), None); assert_eq!( serde_json::to_value(&event).expect("serialize"), @@ -1385,7 +1474,7 @@ mod tests { } // 旧事件(没有 `userItemId`)反序列化成 `None`,回写不补 `null`。 - let legacy: DirectThreadEvent = serde_json::from_value(json!({ + let legacy: ThreadEvent = serde_json::from_value(json!({ "type": "turn.completed", "status": "completed", "at": 3_000u64, @@ -1398,8 +1487,8 @@ mod tests { ); // 条目事件没有这个字段:身份只在生命周期事件上。 - let item_event = DirectThreadEvent::item_completed( - DirectThreadItem::CommandExecution { + let item_event = ThreadEvent::item_completed( + ThreadItem::CommandExecution { item_id: "call-1".to_string(), command: "ls".to_string(), output: None, @@ -1416,7 +1505,7 @@ mod tests { /// 一起流转,缺载荷的 `failed` 事件仍能反序列化(前端按"没有原因"处理,不猜)。 #[test] fn turn_completed_carries_failure_payload_only_when_failed() { - let failed = DirectThreadEvent::turn_completed_failed( + let failed = ThreadEvent::turn_completed_failed( DirectTurnFailure::new( crate::agent::DirectTurnFailureKind::ModelFailed, "上游返回 500:模型服务暂不可用", @@ -1443,7 +1532,7 @@ mod tests { }) ); assert_eq!( - serde_json::from_value::( + serde_json::from_value::( serde_json::to_value(&failed).expect("serialize") ) .expect("round trip"), @@ -1451,7 +1540,7 @@ mod tests { ); // 正常终态不带载荷,也不回写 `failure: null`。 - let completed = DirectThreadEvent::turn_completed("completed".to_string(), 5_000); + let completed = ThreadEvent::turn_completed("completed".to_string(), 5_000); assert_eq!(completed.failure(), None); assert_eq!( serde_json::to_value(&completed).expect("serialize completed turn"), @@ -1459,11 +1548,65 @@ mod tests { ); // 精简 / 旧形状:`failed` 但没有载荷也要能反序列化。 - let sparse: DirectThreadEvent = serde_json::from_value(json!({ + let sparse: ThreadEvent = serde_json::from_value(json!({ "type": "turn.completed", "status": "failed", })) .expect("failed turn without failure payload"); assert_eq!(sparse.failure(), None); } + + /// 待发消息离开队列:`reason` 是 typed 枚举(`cancelled` / `dispatched`),界面按取值分流, + /// 不解析字符串。`at` 缺省时反序列化仍是 `None`。 + #[test] + fn queue_removed_carries_a_typed_reason() { + let dispatched = + ThreadEvent::queue_removed("turn-1".to_string(), QueueRemovalReason::Dispatched, 2_000); + assert_eq!( + serde_json::to_value(&dispatched).expect("serialize queue.removed"), + json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "dispatched", + "at": 2_000u64, + }) + ); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&dispatched).expect("serialize") + ) + .expect("round trip"), + dispatched + ); + assert_eq!(dispatched.queue_client_turn_id(), Some("turn-1")); + assert_eq!( + dispatched.queue_removal_reason(), + Some(QueueRemovalReason::Dispatched) + ); + + // 未识别的取值必须失败关闭:队列归属是宿主事实,不能让界面猜。 + serde_json::from_value::(json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "timeout", + })) + .expect_err("unknown queue removal reason must fail closed"); + + // 没有 `at` 的老形状仍能反序列化,回写不补 `null`。 + let legacy: ThreadEvent = serde_json::from_value(json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "cancelled", + })) + .expect("queue.removed without at"); + assert_eq!(legacy.at(), None); + assert_eq!( + serde_json::to_value(legacy).expect("serialize legacy"), + json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "cancelled", + }) + ); + } } 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 71e58c272..df4800ce8 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 @@ -444,11 +444,6 @@ pub(crate) fn capture_writer_context() -> Option<(Context, AnalyticsWriter)> { Some((capture_analytics_context()?, GUI.get()?.writer.clone())) } -#[tauri::command] -pub(crate) fn settle_direct_run_analytics(attempt_id: String, discard: bool) { - super::run::settle(capture_writer_context(), &attempt_id, discard); -} - fn valid_context(context: &Context) -> bool { GUI.get().is_some_and(|gui| { context.editor_session_id == gui.session_id diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs index 49673bb20..3222a2422 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs @@ -55,15 +55,6 @@ pub(crate) struct Outcome { #[derive(Serialize)] pub(super) enum Request { - DirectCandidate { - attempt_id: String, - terminal: Box, - }, - Settle { - editor_session_id: String, - attempt_id: String, - discard: bool, - }, Accepted { root: PathBuf, project_id: String, @@ -82,34 +73,11 @@ pub(super) enum Request { impl Request { pub(super) fn session_id(&self) -> &str { match self { - Self::DirectCandidate { terminal, .. } => terminal.session_id(), - Self::Settle { - editor_session_id, .. - } => editor_session_id, Self::Accepted { metadata, .. } => &metadata.context.editor_session_id, Self::Terminal { context, .. } => &context.editor_session_id, } } pub(super) fn validate(&self) -> bool { - match self { - Self::DirectCandidate { - attempt_id, - terminal, - } => { - return uuid::Uuid::parse_str(attempt_id).is_ok() - && matches!(terminal.as_ref(), Self::Terminal { metadata, .. } if metadata.source == Source::Direct) - && terminal.validate(); - } - Self::Settle { - editor_session_id, - attempt_id, - .. - } => { - return uuid::Uuid::parse_str(editor_session_id).is_ok() - && uuid::Uuid::parse_str(attempt_id).is_ok(); - } - _ => {} - } let (root, project_id, metadata) = match self { Self::Accepted { root, @@ -122,7 +90,6 @@ impl Request { metadata, .. } => (root, project_id, metadata), - _ => return false, }; root.is_absolute() && root.as_os_str().len() <= 32768 @@ -166,44 +133,29 @@ pub(crate) fn finished( }); } +/// Direct 回合终态:成绩由宿主自己结算,中间没有候选表,也没有第二次 settle。 +/// +/// `release_identity_generation` 是放行那一刻的身份代次(由放行侧读并随这一轮传下来)。 +/// 终态再读一次,不同就整条不记:换了号 / 退出之后这一轮的成绩不属于任何在册身份。 +/// 判据口径因此从"发送时与终态同代"变成"放行时与终态同代"——入队后、放行前发生的账号切换 +/// 不再丢弃成绩,那一轮确实是在新身份下跑的。 pub(crate) fn direct_finished( capture: Option<(Context, AnalyticsWriter)>, root: &Path, project_id: &str, metadata: &Metadata, - attempt_id: Option<&str>, + release_identity_generation: u64, outcome: Outcome, ) { - let Some(attempt_id) = attempt_id else { return }; - let Some((mut context, writer)) = capture else { - return; - }; if metadata.source != Source::Direct { return; } - context.route = metadata.context.route.clone(); - writer.try_run(Request::DirectCandidate { - attempt_id: attempt_id.into(), - terminal: Box::new(Request::Terminal { - root: root.into(), - project_id: project_id.into(), - metadata: metadata.clone(), - context, - event_time: contract::timestamp_now(), - outcome, - }), - }); -} - -pub(crate) fn settle(capture: Option<(Context, AnalyticsWriter)>, attempt_id: &str, discard: bool) { - let Some((context, writer)) = capture else { + if crate::platform_session::current_platform_session_write_state().identity_generation + != release_identity_generation + { return; - }; - writer.try_run(Request::Settle { - editor_session_id: context.editor_session_id, - attempt_id: attempt_id.into(), - discard, - }); + } + finished(capture, root, project_id, metadata, outcome); } #[derive(Serialize, Deserialize)] @@ -266,7 +218,6 @@ pub(super) fn process(request: Request) -> Result, String metadata, .. } => (root, project_id, metadata), - _ => return Ok(None), }; let Some(_lock) = crate::agent::try_acquire_game_creator_agent_runtime_task_lock(root, "analytics-runs")? @@ -387,7 +338,6 @@ pub(super) fn process(request: Request) -> Result, String slot.terminal_consumed = true; Some((context.route.clone(), event)) } - _ => return Ok(None), }; crate::agent::write_agent_runtime_json_sidecar_with_max_bytes( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs index 3aa951715..445548eaf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs @@ -81,7 +81,7 @@ impl AnalyticsWriter { worker_counters .queued_bytes .fetch_sub(bytes, Ordering::Relaxed); - store.run_request(request, bytes); + store.run_request(request); } Ok(Command::Goal(request, bytes)) => { worker_counters @@ -309,8 +309,6 @@ struct Batch { } struct Store { - pending_runs: VecDeque<(String, super::run::Request, u64)>, - pending_run_bytes: u64, root: PathBuf, _owner: File, projects: HashMap, @@ -342,8 +340,6 @@ impl Store { let owner = session::claim(&instance)?; session::recover(&instances, &session_id); let mut store = Self { - pending_runs: VecDeque::new(), - pending_run_bytes: 0, root, _owner: owner, projects: HashMap::new(), @@ -370,57 +366,11 @@ impl Store { Ok(store) } - fn run_request(&mut self, request: super::run::Request, bytes: u64) { - use super::run::Request; + fn run_request(&mut self, request: super::run::Request) { if !request.validate() || request.session_id() != self.session_id { self.counters.dropped.fetch_add(1, Ordering::Relaxed); return; } - let request = match request { - Request::DirectCandidate { - attempt_id, - terminal, - } => { - if bytes > MAX_BYTES as u64 { - return; - } - if self.pending_runs.iter().any(|(id, _, _)| id == &attempt_id) { - return; - } - while self.pending_runs.len() >= 16 - || self.pending_run_bytes + bytes > MAX_BYTES as u64 - { - let Some((_, _, removed)) = self.pending_runs.pop_front() else { - break; - }; - self.pending_run_bytes -= removed; - self.counters.dropped.fetch_add(1, Ordering::Relaxed); - } - self.pending_run_bytes += bytes; - self.pending_runs.push_back((attempt_id, *terminal, bytes)); - return; - } - Request::Settle { - attempt_id, - discard, - .. - } => { - let Some(index) = self - .pending_runs - .iter() - .position(|(id, _, _)| id == &attempt_id) - else { - return; - }; - let (_, terminal, removed) = self.pending_runs.remove(index).unwrap(); - self.pending_run_bytes -= removed; - if discard { - return; - } - terminal - } - other => other, - }; match super::run::process(request) { Ok(Some((route, event))) => { let fact = format!("{}:terminal", event.agent_run_id.as_deref().unwrap()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs index 219949cc9..2e0af4155 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs @@ -1079,167 +1079,75 @@ fn missing_overwritten_or_unwritable_run_slots_do_not_fabricate_terminal_events( fs::remove_dir(backup).unwrap(); } +/// Direct 成绩由宿主直写终态:放行代次与终态代次相同就落一条成绩事件,中间没有候选表。 #[test] -fn direct_terminal_waits_for_exact_attempt_settlement_and_discards_intermediate_failures() { - use super::super::{ - contract::{ErrorCode, RunEndReason, RunSource, Source}, - run, - }; - let (_project_dir, root) = goal_project(); - let config = tempfile::tempdir().unwrap(); - let (context, writer) = goal_writer(config.path(), "A"); - let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); - run::accepted(&writer, &root, "goal-project", &metadata); - let first = uuid::Uuid::new_v4().to_string(); - let final_attempt = uuid::Uuid::new_v4().to_string(); - let capture = || Some((context.clone(), writer.clone())); - let mut failure = run_outcome(); - failure.end_reason = RunEndReason::Failed; - failure.error_code = Some(ErrorCode::ProviderTimeout); - run::direct_finished( - capture(), - &root, - "goal-project", - &metadata, - Some(&first), - failure.clone(), - ); - run::settle(capture(), &uuid::Uuid::new_v4().to_string(), false); - assert!(!drain_goal_writer(config.path(), &context, &writer) - .iter() - .any(|e| e.agent_run_id.is_some())); - // 另一个被取消的尝试不消费槽位;保留第一次失败,验证最终确认不会选错它。 - let cancelled = uuid::Uuid::new_v4().to_string(); - run::direct_finished( - capture(), - &root, - "goal-project", - &metadata, - Some(&cancelled), - failure, - ); - run::settle(capture(), &cancelled, true); - run::settle(capture(), &cancelled, false); - assert!(!drain_goal_writer(config.path(), &context, &writer) - .iter() - .any(|e| e.agent_run_id.is_some())); - run::direct_finished( - capture(), - &root, - "goal-project", - &metadata, - None, - run_outcome(), - ); - run::direct_finished( - capture(), - &root, - "goal-project", - &metadata, - Some(&final_attempt), - run_outcome(), - ); - assert!(!drain_goal_writer(config.path(), &context, &writer) - .iter() - .any(|e| e.agent_run_id.is_some())); - run::settle(capture(), &final_attempt, false); - run::settle(capture(), &final_attempt, false); - let events = drain_goal_writer(config.path(), &context, &writer); - let terminal: Vec<_> = events.iter().filter(|e| e.agent_run_id.is_some()).collect(); - assert_eq!(terminal.len(), 1); - assert_eq!(terminal[0].event_name, "agent_run_completed"); - assert_eq!(terminal[0].event_id, metadata.terminal_event_id); -} - -#[test] -fn direct_pending_capacity_evicts_oldest_without_settlement_fallback() { +fn direct_terminal_writes_once_when_the_identity_generation_is_unchanged() { use super::super::{ contract::{RunSource, Source}, run, }; + let _session = crate::platform_session::install_test_platform_session( + "A", + "token-a", + "https://dev.genarrative.world", + ); + let release_generation = + crate::platform_session::current_platform_session_write_state().identity_generation; let (_project_dir, root) = goal_project(); let config = tempfile::tempdir().unwrap(); let (context, writer) = goal_writer(config.path(), "A"); let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); run::accepted(&writer, &root, "goal-project", &metadata); - let ids: Vec<_> = (0..17).map(|_| uuid::Uuid::new_v4().to_string()).collect(); - for id in &ids { - run::direct_finished( - Some((context.clone(), writer.clone())), - &root, - "goal-project", - &metadata, - Some(id), - run_outcome(), - ); - } - run::settle(Some((context.clone(), writer.clone())), &ids[0], false); - assert!(!drain_goal_writer(config.path(), &context, &writer) - .iter() - .any(|e| e.agent_run_id.is_some())); - assert!(writer.counters.dropped.load(Ordering::Relaxed) >= 1); - run::settle(Some((context.clone(), writer.clone())), &ids[16], false); - assert_eq!( - drain_goal_writer(config.path(), &context, &writer) - .iter() - .filter(|e| e.agent_run_id.is_some()) - .count(), - 1 + run::direct_finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + &metadata, + release_generation, + run_outcome(), ); + let events = drain_goal_writer(config.path(), &context, &writer); + let terminal: Vec<_> = events.iter().filter(|e| e.agent_run_id.is_some()).collect(); + assert_eq!(terminal.len(), 1, "一轮只落一条成绩:{events:?}"); + assert_eq!(terminal[0].event_name, "agent_run_completed"); + assert_eq!(terminal[0].event_id, metadata.terminal_event_id); } +/// 放行之后换号 / 退出:终态代次不再相同,这一轮的成绩整条不记。 #[test] -fn direct_pending_byte_budget_and_session_are_checked_before_consumption() { +fn direct_terminal_is_dropped_when_the_identity_generation_changed_since_release() { use super::super::{ - contract::{Context, RunSource, Source}, + contract::{RunSource, Source}, run, }; + let _session = crate::platform_session::install_test_platform_session( + "A", + "token-a", + "https://dev.genarrative.world", + ); + let release_generation = + crate::platform_session::current_platform_session_write_state().identity_generation; + let (_project_dir, root) = goal_project(); let config = tempfile::tempdir().unwrap(); - let mut store = open(config.path()); - let context = Context { - route: route("A"), - editor_session_id: store.session_id.clone(), - client_version: "1.0.0".into(), - }; + let (context, writer) = goal_writer(config.path(), "A"); let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); - let mut last = String::new(); - for _ in 0..10 { - last = uuid::Uuid::new_v4().to_string(); - // worker接收到的序列化预算是独立的保守保留量。 - store.run_request( - run::Request::DirectCandidate { - attempt_id: last.clone(), - terminal: Box::new(run::Request::Terminal { - root: config.path().into(), - project_id: "project".into(), - metadata: metadata.clone(), - context: context.clone(), - event_time: "2026-09-21T00:00:00.000Z".into(), - outcome: run_outcome(), - }), - }, - 128 * 1024, - ); - } - assert_eq!(store.pending_runs.len(), 8); - assert_eq!(store.pending_run_bytes, MAX_BYTES as u64); - store.run_request( - run::Request::Settle { - editor_session_id: uuid::Uuid::new_v4().to_string(), - attempt_id: last.clone(), - discard: true, - }, - 128, + run::accepted(&writer, &root, "goal-project", &metadata); + // 这一轮还在跑的时候身份代次推进了(换号 / 退出)。 + let next = + crate::platform_session::current_platform_session_write_state().identity_generation + 1; + crate::platform_session::clear_platform_session(next, next); + run::direct_finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + &metadata, + release_generation, + run_outcome(), ); - assert_eq!(store.pending_runs.len(), 8); - store.run_request( - run::Request::Settle { - editor_session_id: context.editor_session_id, - attempt_id: last, - discard: true, - }, - 128, + assert!( + drain_goal_writer(config.path(), &context, &writer) + .iter() + .all(|event| event.agent_run_id.is_none()), + "终态代次不同时不能落任何成绩" ); - assert_eq!(store.pending_runs.len(), 7); - assert_eq!(store.pending_run_bytes, 7 * 128 * 1024); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 05c532102..1850fe457 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -14,10 +14,6 @@ pub(crate) enum CliCommand { agent_id: String, prompt: String, }, - DirectCodexChat { - project_path: PathBuf, - prompt: String, - }, AgentTask { project_path: PathBuf, agent_id: String, @@ -178,7 +174,6 @@ impl CliCommand { .. } => Some((project_path, *initialize)), Self::AgentChat { project_path, .. } - | Self::DirectCodexChat { project_path, .. } | Self::AgentRuntimeStatus { project_path, .. } | Self::AgentContextCompact { project_path, .. } | Self::AgentGoalStatus { project_path, .. } @@ -699,19 +694,6 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S prompt: prompt.to_string(), })); } - if args.first().map(String::as_str) == Some("--direct-codex-chat") { - if args.len() < 3 { - return Err("用法:--direct-codex-chat <本地项目绝对路径> <聊天内容>".to_string()); - } - let prompt = args[2..].join(" "); - if prompt.trim().is_empty() { - return Err("聊天内容不能为空".to_string()); - } - return Ok(Some(CliCommand::DirectCodexChat { - project_path: PathBuf::from(&args[1]), - prompt: prompt.trim().to_string(), - })); - } if args.first().map(String::as_str) == Some("--agent-task") { let mut rest = args[1..].to_vec(); let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { @@ -896,44 +878,6 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { println!("replyText={}", reply.reply_text); Ok(()) } - CliCommand::DirectCodexChat { - project_path, - prompt, - } => { - let project_path = canonicalize_cli_path(&project_path, "本地项目路径", true)?; - if !project_path.join(".agent/manifest.json").is_file() { - let project_name = project_path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("Codex 直连项目"); - init_local_game_project_at( - &project_path, - &format!("direct-codex-{}", unix_millis()), - project_name, - )?; - } - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|error| format!("创建 CLI runtime 失败:{error}"))?; - let reply_result = runtime.block_on(async { - run_direct_game_creator_turn_at(&project_path, &prompt) - .await - // CLI 也是命令边界:typed 错误在这里序列化成一行给终端看的文本;可留痕的 - // 调用级拒绝(宿主 / 环境事实)与 GUI 走同一份投影(同一份 `Display` 文案, - // 不另加 `详情:` 引用),差别只在 CLI 自己 await 整轮、拿到回复文本。 - .map_err(|failure| { - direct_turn_error_boundary_text(&project_path, None, failure) - }) - }); - let shutdown_result = shutdown_game_creator_codex_app_servers(); - let reply = reply_result?; - shutdown_result?; - println!("direct-codex.chat.completed"); - println!("projectPath={}", project_path.display()); - println!("replyText={reply}"); - Ok(()) - } CliCommand::AgentTask { project_path, agent_id, 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 de54ffa03..072750eca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5893,20 +5893,39 @@ pub(crate) async fn read_direct_project_conversation( } #[tauri::command] -pub(crate) fn list_game_creator_direct_active_turns( -) -> Result, String> { - list_direct_active_turns() +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 { +) -> Result { tauri::async_runtime::spawn_blocking(move || { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - let thread_id = direct_thread_id_for_project(root); - let mut bootstrap = subscribe_direct_thread(&thread_id); + 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) => { @@ -5915,7 +5934,7 @@ pub(crate) async fn subscribe_direct_project_thread( Err(error) => { // 订阅已经登记,后续锚点读取失败时也必须回收 native // subscriber;否则前端拿不到 subscriptionId,无法自行清理。 - unsubscribe_direct_thread(&bootstrap.subscription_id); + unsubscribe_thread(&bootstrap.subscription_id); return Err(error); } } @@ -5929,13 +5948,13 @@ pub(crate) async fn subscribe_direct_project_thread( #[tauri::command] pub(crate) fn consume_direct_project_thread( subscription_id: String, -) -> Result { - consume_direct_thread(subscription_id.trim()) +) -> Result { + consume_thread(subscription_id.trim()) } #[tauri::command] pub(crate) fn unsubscribe_direct_project_thread(subscription_id: String) -> Result<(), String> { - unsubscribe_direct_thread(subscription_id.trim()); + unsubscribe_thread(subscription_id.trim()); Ok(()) } @@ -5950,7 +5969,7 @@ pub(crate) async fn read_direct_project_history_slice( before_item_id: Option, through_item_id: Option, limit: Option, -) -> Result { +) -> Result { tauri::async_runtime::spawn_blocking(move || { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; @@ -5967,12 +5986,12 @@ pub(crate) async fn read_direct_project_history_slice( }; 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 = direct_thread_items_from_history(root, &raw_items, |item| { - direct_thread_item_identity(item) + 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(DirectThreadHistorySlice { + Ok(HistorySlice { items, has_more, first_item_id, 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 9f4fe3db3..31d9323e1 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}; @@ -2565,7 +2570,7 @@ fn main() { })?; setup_log.append("startup.runner.start.begin"); set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); - set_direct_thread_manager_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()); @@ -2590,7 +2595,6 @@ fn main() { }) .invoke_handler(tauri::generate_handler![ analytics::gui::capture_analytics_context, - analytics::gui::settle_direct_run_analytics, analytics::gui::record_analytics_project_open, analytics::gui::record_analytics_project_leave, analytics::gui::record_analytics_ui_save, @@ -2635,7 +2639,8 @@ fn main() { chat_with_game_creator_agent, chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, - chat_with_game_creator_direct_codex, + enqueue_direct_codex_turn, + remove_direct_project_pending_turn, preflight_web_game_creation, cancel_direct_codex_turn, select_game_creator_reasoning_effort, 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-tauri/tests/prompt_source_boundaries.rs b/apps/ai-game-creator-shell/src-tauri/tests/prompt_source_boundaries.rs index 453b77e00..5ed98b980 100644 --- a/apps/ai-game-creator-shell/src-tauri/tests/prompt_source_boundaries.rs +++ b/apps/ai-game-creator-shell/src-tauri/tests/prompt_source_boundaries.rs @@ -258,7 +258,7 @@ fn prompt_entry_points_load_their_prose_from_external_files() { ), ( "src/agent/direct_codex_user_item/wire.rs", - &["render_ui_design_code_context"], + &["ui_design_code_context_for"], ), ( "src/commands.rs", diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 2d68e7929..ee7b515f6 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -954,7 +954,7 @@ function directCodexDiagnosticFailureParts( return { stageLabel, summary, hint, retryable: retryable === 'true' }; } -/** 收口文案的一句话:阶段标签只有"回合失败"才成立,拒单那边传 `null`(见下面的导出函数)。 */ +/** 收口文案的一句话:阶段标签只有"回合失败"才成立,请求被拒那边传 `null`(见下面的导出函数)。 */ function directDiagnosticSentence( parts: DirectDiagnosticParts, stageLabel: string | null, @@ -970,10 +970,10 @@ function directCodexDiagnosticFailureDetail(message: string) { } /** - * **拒单**的可见文案:宿主收口文案里那段已脱敏的摘要与建议。 + * **请求被拒**的可见文案:宿主收口文案里那段已脱敏的摘要与建议。 * * 与 [`projectRuntimeVisibleError`] 的差别只有一处——**不带阶段标签**:阶段说的是"失败发生在交付的 - * 哪一步",而拒单是"这一轮没有开始",阶段只会是默认值(`code-generation`),套上去会把没发生的 + * 哪一步",而请求被拒是"这一轮没有开始",阶段只会是默认值(`code-generation`),套上去会把没发生的 * 事讲成发生了。宿主的文案不是收口形状时退回同一份运行错误映射。 */ export function projectRuntimeVisibleRejectionError( diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index c18a6da4f..1b4a52659 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -1113,11 +1113,6 @@ export function RuntimeConfigDialog({
{plugin.name} 内置 Plugin · 不可卸载 - {plugin.lastError ? ( - - {plugin.lastError} - - ) : null}
@@ -1258,11 +1253,6 @@ export function RuntimeConfigDialog({ {typeLabel} · 来自 {item.sourceName} - {item.lastError ? ( - - {item.lastError} - - ) : null}
diff --git a/apps/ai-game-creator-shell/src/services/clientAnalytics.ts b/apps/ai-game-creator-shell/src/services/clientAnalytics.ts index 7e5c928c6..453c7ce85 100644 --- a/apps/ai-game-creator-shell/src/services/clientAnalytics.ts +++ b/apps/ai-game-creator-shell/src/services/clientAnalytics.ts @@ -1,39 +1,5 @@ import type { TauriInvoke } from '../app/types'; -// 一个 run 可因自动认证刷新调用多次 native,只确认最后一次尝试。 -export function beginDirectRunAnalytics( - invoke: TauriInvoke, - currentGeneration: () => number, -) { - const generation = currentGeneration(); - let attemptId: string | undefined; - let settled = false; - return { - nextAttempt() { - attemptId = undefined; - try { - attemptId = crypto.randomUUID(); - } catch { - // 最后一次 UUID 失败不能留下前一次失败候选的标识。 - } - return attemptId; - }, - settle() { - if (settled) return; - settled = true; - if (!attemptId) return; - try { - void invoke('settle_direct_run_analytics', { - attemptId, - discard: currentGeneration() !== generation, - }).catch(() => undefined); - } catch { - // 不等待埋点,不改变调用链的成功、失败和取消结果。 - } - }, - }; -} - // 宿主签发的无凭据上下文:前端只传回原值,不从当前登录态补身份。 type AnalyticsContext = { route: { user_id: string | null; destination_origin: string | null }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index becc0792a..e10829c5f 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -130,8 +130,9 @@ export function DirectProjectChatView({ appendLocalMessage, attachmentNotice, attachmentsImporting, - cancelQueuedTurn, + cancelPendingTurn, cancelTurn, + commandInFlight, composerNotice, directEntries, directTurnRunning, @@ -139,11 +140,10 @@ export function DirectProjectChatView({ historyHasMore, loadEarlierHistory, localMessages, - queuedTurns, + pendingTurns, startInitialTurn, statusNotice, submit, - turnBusy, turnCancelling, uploadFiles, } = chat; @@ -161,14 +161,15 @@ export function DirectProjectChatView({ }), [directEntries, localMessages, directTurnRunning, directTurnStartedAt], ); - // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮两态。 + // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / IPC 在飞 / 待发消息条数 / 最新一轮两态。 const turnStatus = useDirectProjectTurnStatus({ turnRunning: directTurnRunning, - turnBusy, + commandInFlight, + pendingTurns, turns: directTurns, }); // 状态条的起点只认**未结束**的最新一轮:只有它才拿得到本轮的 `turn.started.at`(运行中读实时值)。 - // 只可能是最后一轮:原生 `turnRunning` 只赋给最新一轮。接单窗口里还没有这一轮的条目, + // 只可能是最后一轮:原生 `turnRunning` 只赋给最新一轮。入队窗口里还没有这一轮的条目, // 于是这里是 0,卡片只报"正在处理"、不读秒(宿主开始事件一到就开始读秒)。 const latestTurn = directTurns.at(-1) ?? null; const activeTurnStartedAt = @@ -273,11 +274,14 @@ export function DirectProjectChatView({ projectPath={projectPath} attachmentNotice={attachmentNotice} attachmentsImporting={attachmentsImporting} - queuedTurns={queuedTurns} + pendingTurns={pendingTurns} composerNotice={composerNotice} busy={turnStatus.displayBusy} + canStopTurn={turnStatus.canStopTurn} turnCancelling={turnCancelling} - onCancelQueuedTurn={cancelQueuedTurn} + onCancelPendingTurn={(clientTurnId) => + void cancelPendingTurn(clientTurnId) + } onCancelTurn={() => void cancelTurn()} onReferencePickerOpen={() => { // 素材标签、改名、删除这些写入发生在资源画布,聊天侧收不到失效事件: diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx index dc4904ed0..3db8b8f3b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx @@ -3,7 +3,7 @@ * 模型 + 麦克风 + 发送/终止,以及输入盒上方的待发附件与消息队列 chip。 * * 这些组件只承载表现与交互;回合附件由宿主导入后插成正文芯片(正文是唯一事实源), - * 队列由 `chatComposerQueue.ts` 的纯函数维护。 + * 待发消息队列由宿主持有(这里只是一份事件投影,文案派生见 `pendingTurnChipLabel.ts`)。 */ import { Check, @@ -30,8 +30,7 @@ import { DEFAULT_COMPOSER_REASONING_EFFORT, normalizeComposerReasoningEffort, } from '../../../../../features/project-workspace/composerReasoningEffort'; -import type { QueuedChatTurn } from './chatComposerQueue'; -import { queuedChatTurnLabel } from './chatComposerQueue'; +import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; import { resolveSpeechRecognitionCtor, speechEventTranscript, @@ -41,6 +40,7 @@ import { type SpeechRecognitionLike, VOICE_INPUT_UNSUPPORTED_MESSAGE, } from './chatComposerVoice'; +import { pendingTurnChipLabel } from './pendingTurnChipLabel'; type ComposerAttachmentMenuProps = { disabled: boolean; @@ -151,38 +151,46 @@ export function ComposerAttachmentMenu({ ); } -/** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */ +/** + * 待发消息 chip:还没放行的消息,按 FIFO 顺序展示,可单条取消。 + * + * 列表就是宿主事件流折出来的那一份(`DirectPendingTurn`),顺序与宿主认领队首的顺序一致; + * 这里不排序、不重排,也不自己决定放行。 + */ export function ComposerTurnQueue({ turns, assets, onCancel, }: { - turns: readonly QueuedChatTurn[]; + turns: readonly DirectPendingTurn[]; /** 与聊天消息渲染同源的素材清单:chip 文案里的 `@` 引用按它展开成显示名。 */ assets: readonly GameCreationAppAssetManifestEntry[]; - onCancel: (id: string) => void; + onCancel: (clientTurnId: string) => void; }) { if (turns.length === 0) { return null; } return (
    - {turns.map((turn, index) => ( -
  1. - {index + 1} - - {queuedChatTurnLabel(turn, assets)} - - -
  2. - ))} + {turns.map((turn, index) => { + const label = pendingTurnChipLabel(turn, assets); + return ( +
  3. + + {index + 1} + + {label} + +
  4. + ); + })}
); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx index c92cd74d1..f151e8344 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx @@ -23,8 +23,8 @@ import { } from '../../../../../features/project-workspace/ResourceReferencePicker'; import { assetsSignature } from '../../../../../features/project-workspace/resourceReferences'; import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments'; +import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; import type { DirectCodexUserContentPart } from '../../generated/DirectCodexUserContentPart'; -import type { QueuedChatTurn } from './chatComposerQueue'; import { ComposerAttachmentMenu, ComposerReasoningEffortSelect, @@ -43,10 +43,10 @@ function draftAttachmentCount(draft: { } /** - * DirectProject 输入区:队列、附件、`@` 引用输入框、模型/推理选择和发送/终止。 + * DirectProject 输入区:待发消息 chip、附件、`@` 引用输入框、模型/推理选择和发送/终止。 * - * 模型可用性校验、语音提示这类输入区自己的瞬时状态留在这里;回合、队列和附件的事实源 - * 仍然在聊天容器里,这里只负责把用户动作交回去。 + * 模型可用性校验、语音提示这类输入区自己的瞬时状态留在这里;回合、待发消息队列和附件的事实源 + * 仍然在聊天容器里(队列更上游是宿主),这里只负责把用户动作交回去。 */ export function DirectProjectComposer({ assets, @@ -54,11 +54,12 @@ export function DirectProjectComposer({ projectPath, attachmentNotice, attachmentsImporting, - queuedTurns, + pendingTurns, composerNotice, busy, + canStopTurn, turnCancelling, - onCancelQueuedTurn, + onCancelPendingTurn, onCancelTurn, onReferencePickerOpen, onSubmit, @@ -70,11 +71,19 @@ export function DirectProjectComposer({ attachmentNotice: string; /** 附件导入进行中:导入结果还没落成芯片,这一轮先不许发出去。 */ attachmentsImporting: boolean; - queuedTurns: QueuedChatTurn[]; + pendingTurns: readonly DirectPendingTurn[]; composerNotice: string; + /** 「界面忙不忙」:三者并集,用来锁住模型 / 附件 / 语音这些与当前这轮冲突的控件。 */ busy: boolean; + /** + * 「现在点终止有没有作用对象」(见 `useDirectProjectTurnStatus` 的 `canStopTurn`)。 + * + * 与 `busy` 分开:队列非空也算忙,但待发消息不属于「终止」——按 `busy` 显示终止钮会得到一个 + * 点了只会报「当前没有正在运行的回合」的按钮。发送钮 / 终止钮只按它换。 + */ + canStopTurn: boolean; turnCancelling: boolean; - onCancelQueuedTurn: (id: string) => void; + onCancelPendingTurn: (clientTurnId: string) => void; onCancelTurn: () => void; /** * 打开 `@` 选择器前刷新一次项目清单。 @@ -84,7 +93,14 @@ export function DirectProjectComposer({ * 用户真正要看这份清单的时刻是打开选择器的这一刻,所以在这里补一次重读。 */ onReferencePickerOpen?: () => void; - onSubmit: (content: DirectCodexUserContentPart[]) => boolean; + /** + * 提交草稿:返回**是否把这条消息交出去了**。 + * + * `true` 只代表"草稿可以清"——入队成立、或已经交给权限确认流程(确认后会重跑同一份内容); + * 入队失败里用户自己能改的那些返回 `false`,草稿原样留在输入盒里,不让一条被拒的消息从 + * 界面上凭空消失。 + */ + onSubmit: (content: DirectCodexUserContentPart[]) => Promise; /** * 导入文件:`draftAttachmentCount` 是草稿里已有的附件芯片数(上限按它算)。 * 返回导入成功的附件——由这里插成正文芯片;失败的一条都不插入。 @@ -152,7 +168,8 @@ export function DirectProjectComposer({ }, [onUploadFiles], ); - // 这个按钮只在非 busy 分支出现(busy 时由 ComposerStopButton 接管),所以不再带忙态逻辑。 + // 这个按钮只在"没有可终止对象"的分支出现(有的话由 ComposerStopButton 接管), + // 所以不再带忙态逻辑:队列里有待发消息时它照样在,用户能继续排。 const submitButton = ( -
-
+
+
{updateGameId ? '发布新版本' : '发布游戏'} -

+

{updateGameId ? updateContext ? `为《${updateContext.title}》发布 v${updateContext.versionNumber + 1}` @@ -724,6 +725,9 @@ export function GamePublishPage({ : '上传可在线游玩的网页游戏'}

+ {!updateGameId ? ( + + ) : null}
{error ? (