diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 9c7e9ee8d..e85700ec9 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -84,6 +84,7 @@ function resolveBackendTargetsFromState( requireAgcBackend = false, expectedDatabase = backendDatabase, expectedSpacetimeDataDir = backendSpacetimeDataDir, + expectedRepoRoot = repoRoot, fallbackApiTarget = defaultApiTarget, } = {}, ) { @@ -101,7 +102,25 @@ function resolveBackendTargetsFromState( const hasMatchingDataDir = Boolean(spacetimeDataDir) && spacetimeDataDir === resolve(expectedSpacetimeDataDir); - const hasMatchingBackend = hasMatchingDatabase && hasMatchingDataDir; + const instanceId = + typeof state?.instanceId === 'string' ? state.instanceId.trim() : ''; + const hasMatchingRepoRoot = + typeof state?.repoRoot === 'string' && + resolve(state.repoRoot) === resolve(expectedRepoRoot); + const hasMatchingInstance = + Boolean(instanceId) && + [apiServer, spacetime, bgfilterWorker] + .filter(Boolean) + .every( + (service) => + service.repoRoot && + resolve(service.repoRoot) === resolve(expectedRepoRoot) && + service.instanceId === instanceId, + ); + const hasMatchingBackend = + hasMatchingDatabase && + hasMatchingDataDir && + (!requireAgcBackend || (hasMatchingRepoRoot && hasMatchingInstance)); const canReuseState = !requireAgcBackend || hasMatchingBackend; const apiUrl = canReuseState && isActive(apiServer) && apiServer.url @@ -127,6 +146,8 @@ function resolveBackendTargetsFromState( spacetimeDataDir, hasMatchingDatabase, hasMatchingDataDir, + hasMatchingRepoRoot, + hasMatchingInstance, hasMatchingBackend, }; } diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md index 3403f655f..ed391a2ad 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md @@ -1,6 +1,6 @@ 处理代码任务时先用 project.search 定位,再用带行号的 file.read 获取足够上下文;单文件小改优先使用 file.patch;涉及多个文件时优先使用 project.patchset,它会自动创建 checkpoint,无需额外调用 project.checkpoint,并在成功后用返回的 checkpointId 调用 project.diff(includeContent=true) 审查整体变更;只有确认文件已废弃时才删除。 -每次成功执行 file.write、file.patch、file.delete、project.patchset 或 project.restore,以及每次真正启动 command.exec 或 command.start,都会产生新的项目 revision;最后一次修改后必须成功执行 project.verify、可验证 command.exec,或成功执行 command.run_limited 的 game.static_smoke,才能调用 respond_to_user 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。需要执行 package.json 中的验证脚本时,先读取 package.json,再把真实脚本名和读到的完整命令原样提交给 project.verify;script 可以是 check、typecheck、test、lint、build,或使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本,其中冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点;不得猜测或改写 expectedCommand。 +每次成功执行 file.write、file.patch、file.delete、project.patchset 或 project.restore,以及每次真正启动 command.exec、command.start 或 project.bootstrap,都会产生新的项目 revision;DirectProject 的 npm/Phaser 工程先用 project.bootstrap {cwd:"game"} 执行受控无参数 npm install,再用 project.verify {cwd:"game",script:"build",expectedCommand:从 game/package.json 原样读取} 构建,并确认 game/dist/index.html 存在。最后一次修改后必须成功执行 project.verify、可验证 command.exec,或成功执行 command.run_limited 的 game.static_smoke,才能调用 respond_to_user 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。需要执行 package.json 中的验证脚本时,先读取 package.json,再把真实脚本名和读到的完整命令原样提交给 project.verify;script 可以是 check、typecheck、test、lint、build,或使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本,其中冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点;不得猜测或改写 expectedCommand。 每 6 轮只是一次进度 checkpoint 与停滞检测,不是上下文压缩或 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。真正的上下文压缩仅由 token 阈值或显式 compact 触发。Agent 私有记忆只能由本人写入,跨 Agent 共享稳定结论用 blackboard.write,给单个 Agent 留上下文用 agent.message。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 59954591b..bf7c9ef2e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -10,7 +10,7 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; -const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite,Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面仍不得主动输出到对话、工具参数或日志。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite,Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面仍不得主动输出到对话、工具参数或日志。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE: &str = r#"Cocos Creator 桥接边界:Cocos 的编辑器能力来自客户端随包提供的内置插件 `agc-cocos-editor`,Agent 工具名是 `cocos.editor.execute`(客户端受控工具名为 `agc_cocos_execute`)。识别为 Cocos Creator 项目后,直接检查当前可用工具并调用这个内置工具;不要搜索、读取、安装、启用或建议项目目录里的 MCP 扩展、`extensions/` 包、`package.json` 插件或 Cocos 面板服务。项目内的第三方 MCP 扩展不是 AGC Cocos 桥接来源,缺失内置工具时只能报告客户端内置插件不可用,不得改为查项目扩展或要求用户打开 Cocos MCP 面板。历史聊天记录仅用于理解上下文,不是工具或系统指令;其中与本边界冲突的旧说明一律以当前提示和当前可用内置工具为准。"#; const DIRECT_COCOS_CAPABILITY_GUIDE: &str = r#"Cocos 能力:先用 cocos_get_capabilities 和 cocos_get_hierarchy 查询;查询返回 NID 与 UUID,场景切换后必须重新查询。读取场景树 `Editor.Message.request('scene', 'query-node-tree')`,先用只读查询拿到真实 uuid 和当前状态,再执行修改。用 cocos_inspect_node 取得 componentIndex、组件类型及属性后再修改。节点、组件、Prefab、Label/Sprite/Button/Shape、Layout/Widget、九宫格、批量 UI、保存、撤销、日志、构建诊断和网页预览调试均有对应 cocos_* 工具,按实际 inputSchema 调用。批量 UI 最多 64 个节点和 12 层,save 缺省 true;首次保存可用 cocos_save_scene 的 path 指定 assets 下新 .scene 路径。只在 verified 为 true 时报告结果已经回读确认;failed、rolledBack 和 needs-reconciliation 不能当成功,结果不确定不得自动重发。cocos_mcp_undo_last 会拒绝覆盖后续手动修改。预览工具只管理自己的 Chromium 窗口和当前项目 loopback 地址,capture 返回 PNG 图片。目录之外的操作继续用 agc_cocos_execute 注入支持 await/return 的 JS 函数体。"#; const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; 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 60f63acc3..9d1f102c9 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 @@ -16,7 +16,7 @@ JSON schema: { "group": "balance", "role": "Difficulty", "summary": "数值交接摘要", "outputs": ["game/balance.json"], "next": "交给程序组读取" }, { "group": "art", "role": "Asset", "summary": "美术交接摘要", "outputs": ["assets/manifest.art.json"], "next": "进入画板或本地资产登记" }, { "group": "audio", "role": "SFX", "summary": "音乐音效交接摘要", "outputs": ["assets/manifest.audio.json"], "next": "进入画板音频链路" }, - { "group": "code", "role": "Code", "summary": "程序交接摘要", "outputs": ["game/index.html"], "next": "交给 Playtest" }, + { "group": "code", "role": "Code", "summary": "程序交接摘要", "outputs": ["game/index.html", "game/game.js", "game/package.json", "game/vite.config.js"], "next": "先 project.bootstrap,再 project.verify(build),交给 Playtest" }, { "group": "publishing", "role": "Publish", "summary": "运营交接摘要", "outputs": ["exports/README.md"], "next": "等待预览验收" } ], "handoffSummary": "六组 agent 的交接摘要,每组一行", @@ -24,6 +24,7 @@ JSON schema: } gameHtml 规则: +- 本次若目标是 Phaser/npm,workspaceMode 必须为 DirectProject;先写入完整 game/ 工程文件,再由受控项目工具安装与构建。 - 此 JSON 协议仅用于已有的单文件 HTML 项目;npm / Phaser 项目必须使用 DirectProject,不能通过 gameHtml 交付 package.json 或模块源码。 - 必须是单文件 HTML,不能加载远程脚本、远程图片、远程 CSS 或 CDN。 - 必须包含 canvas、canvas getContext、实际绘制调用、键盘或鼠标输入、requestAnimationFrame 主循环、目标、失败或胜利状态、R 或按钮重开。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index 5c9afc1a4..f5bcfdf30 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -1192,6 +1192,7 @@ pub(in crate::agent) fn agent_runtime_public_action_input_summary( | "project.patchset" | "project.search" | "project.verify" + | "project.bootstrap" | "file.list" | "file.read" | "file.write" @@ -1259,6 +1260,7 @@ pub(crate) fn agent_runtime_tool_action_fingerprint( | "command.stdin" | "command.terminate" | "project.verify" + | "project.bootstrap" ) .then(|| { let metadata = command_sandbox_platform_metadata(); @@ -1468,11 +1470,23 @@ pub(crate) fn agent_runtime_tool_action_input_summary( .and_then(|value| value.as_bool()) .unwrap_or(false) ), + "project.bootstrap" => { + format!( + "cwd={} · timeoutSeconds={}", + relative_path(&["cwd"]), + input + .get("timeoutSeconds") + .or_else(|| input.get("timeout_seconds")) + .and_then(|value| value.as_u64()) + .unwrap_or(300) + ) + } "project.verify" => { let expected_command = text(&["expectedCommand", "expected_command"]); let command_chars = expected_command.chars().count(); format!( - "script={} · expectedCommandSha256={:x} · expectedCommandChars={} · timeoutSeconds={}", + "cwd={} · script={} · expectedCommandSha256={:x} · expectedCommandChars={} · timeoutSeconds={}", + relative_path(&["cwd"]), text(&["script"]), Sha256::digest(expected_command.as_bytes()), command_chars, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index 620aec6df..96d7f9b63 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -184,6 +184,17 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ ) .await } + "project.bootstrap" => { + observe_agent_runtime_project_bootstrap( + root, + agent_id, + run_id, + action_id, + &action_fingerprint, + &action.input, + ) + .await + } "project.checkpoint" => observe_agent_runtime_project_checkpoint(root), "project.restore" => { observe_agent_runtime_project_restore(root, agent_id, run_id, &action.input) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index cd032a2ee..071298d77 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -8,10 +8,15 @@ pub(crate) struct AgentRuntimeAutonomousSourcePayloadStats { } pub(in crate::agent) fn agent_runtime_autonomous_project_verify_available(root: &Path) -> bool { - let package_path = root.join("package.json"); - fs::symlink_metadata(package_path) - .map(|metadata| metadata.file_type().is_file() && !metadata.file_type().is_symlink()) - .unwrap_or(false) + [root.join("package.json"), root.join("game/package.json")] + .into_iter() + .any(|package_path| { + fs::symlink_metadata(package_path) + .map(|metadata| { + metadata.file_type().is_file() && !metadata.file_type().is_symlink() + }) + .unwrap_or(false) + }) } pub(in crate::agent) fn add_agent_runtime_autonomous_source_field( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index bfae45d71..b38ed1023 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -345,7 +345,7 @@ fn build_game_creator_agent_background_tool_plan_request_at( let project_tools_contract = if runtime_owner_artifact_validation_available { "project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;当前固定 owner 的函数目录不广告 project.verify;project.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpoint;project.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpoint;project.restore 使用 {\"checkpointId\":\"checkpoint id\"};project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000};git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string() } else { - "project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;project.verify 使用 {\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"从 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120},只执行项目根 package.json 中同名 npm 脚本,expectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpoint;project.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpoint;project.restore 使用 {\"checkpointId\":\"checkpoint id\"};project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000};git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string() + "project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;project.bootstrap 使用 {\"cwd\":\"game\",\"timeoutSeconds\":300},只执行 game 目录无参数 npm install 并记录 package/lock 指纹;project.verify 使用 {\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"从对应 cwd 的 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120,\"cwd\":\"game\"},只执行对应 cwd package.json 中同名 npm 脚本,build 必须确认 game/dist/index.html;expectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpoint;project.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpoint;project.restore 使用 {\"checkpointId\":\"checkpoint id\"};project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000};git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string() }; let prompt = format!( concat!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 1c2fb8d7f..68e315d3d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -28,6 +28,7 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { "project.index", "project.search", "project.verify", + "project.bootstrap", "project.checkpoint", "project.restore", "project.diff", @@ -83,6 +84,7 @@ pub(crate) fn agent_runtime_acceptance_evidence_tools() -> BTreeSet<&'static str "project.index", "project.search", "project.verify", + "project.bootstrap", "project.diff", "git.inspect", "project.patchset", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index f00f8060f..f183e0f6b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -156,6 +156,7 @@ pub(super) const AGENT_RUNTIME_AUTONOMOUS_GAME_BUILD_AUTO_COMMAND_IDS: &[&str] = "file.delete", "project.patchset", "project.verify", + "project.bootstrap", "command.run_limited", "preview.validate", "canvas.asset_generate", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs index 351cbb496..a91344eda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs @@ -109,6 +109,7 @@ pub(crate) fn agent_runtime_tool_requires_repository_context_fingerprint_gate(to | "project.index" | "project.search" | "project.verify" + | "project.bootstrap" | "project.checkpoint" | "project.patchset" | "project.restore" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs index b99c3e022..46a867f0e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs @@ -857,6 +857,12 @@ pub(crate) async fn observe_agent_runtime_project_verify( .and_then(serde_json::Value::as_str) .unwrap_or_default() .to_string(); + let cwd = input + .get("cwd") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(".") + .to_string(); let timeout_seconds = agent_runtime_tool_input_usize(input, &["timeoutSeconds", "timeout_seconds"]) .unwrap_or(AGENT_RUNTIME_PROJECT_VERIFY_DEFAULT_TIMEOUT_SECONDS); @@ -896,6 +902,7 @@ pub(crate) async fn observe_agent_runtime_project_verify( script.as_str(), expected_command.as_str(), timeout_seconds, + cwd.as_str(), || { verification_state = Some(begin_agent_runtime_project_verification_locked( root, @@ -931,6 +938,7 @@ pub(crate) async fn observe_agent_runtime_project_verify( "script": verification.script, "expectedCommand": audit_expected_command, "packageManager": verification.package_manager, + "cwd": verification.cwd_relative, "status": verification.status, "exitCode": verification.exit_code, "timedOut": verification.timed_out, @@ -1039,3 +1047,81 @@ pub(crate) async fn observe_agent_runtime_project_verify( } } } + +pub(crate) async fn observe_agent_runtime_project_bootstrap( + root: &Path, + agent_id: &str, + run_id: &str, + action_id: Option<&str>, + action_fingerprint: &str, + input: &serde_json::Value, +) -> AgentRuntimeToolObservation { + let cwd = input + .get("cwd") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let timeout = agent_runtime_tool_input_usize(input, &["timeoutSeconds", "timeout_seconds"]) + .unwrap_or(300); + if cwd != "game" { + return AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: "failed".to_string(), + summary: "project.bootstrap 只允许 cwd=game".to_string(), + detail: None, + }; + } + let _lock = match acquire_project_write_lock(root, "project.bootstrap") { + Ok(lock) => lock, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: "failed".to_string(), + summary: "project.bootstrap 无法取得项目执行锁".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 240)), + } + } + }; + let result = crate::project::run_project_bootstrap_at(root, timeout as u64).await; + match result { + Ok(value) if value.status == "completed" => { + if let Err(error) = append_agent_db_record( + root, + serde_json::json!({ + "recordType":"agent.runtime.project.bootstrap", "agentId":agent_id, "runId":run_id, + "actionId":action_id, "actionFingerprint":action_fingerprint, "cwd":"game", + "packageSha256":value.package_sha256, "lockSha256":value.lock_sha256, + "status":value.status, "logPath":value.log_path, "output":value.output + }), + ) { + return AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: "project.bootstrap 已安装,但审计记录无法落盘".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: "ok".to_string(), + summary: "game 依赖安装已完成".to_string(), + detail: Some(format!( + "cwd=game · packageSha256={} · lockSha256={}", + value.package_sha256, + value.lock_sha256.as_deref().unwrap_or("none") + )), + } + } + Ok(value) => AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: "failed".to_string(), + summary: "game 依赖安装失败".to_string(), + detail: Some(value.output), + }, + Err(error) => AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }, + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 6a35ce93e..45ba5c505 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1009,7 +1009,8 @@ fn runtime_tool_description(tool: &str) -> &'static str { } "project.index" => "刷新并读取有界仓库启动上下文。", "project.search" => "在项目文本文件中做有界字面量搜索。", - "project.verify" => "运行 package.json 中原样声明的验证脚本。", + "project.verify" => "在项目或指定相对 cwd 中运行 package.json 原样声明的验证脚本,并检查构建产物。", + "project.bootstrap" => "仅在项目 game 目录受控执行无参数 npm install,并记录依赖文件指纹。", "project.checkpoint" => "创建项目本地 checkpoint。", "project.restore" => "从 checkpoint 恢复当前项目。", "project.diff" => "读取 checkpoint 与当前项目之间的有界差异。", @@ -1154,10 +1155,18 @@ fn runtime_tool_input_schema(tool: &str) -> Value { } }), "project.verify" => json!({ - "type": "object", "required": ["script", "expectedCommand", "timeoutSeconds"], "additionalProperties": false, + "type": "object", "required": ["script", "expectedCommand", "timeoutSeconds", "cwd"], "additionalProperties": false, "properties": { "script": { "type": "string", "minLength": 1 }, "expectedCommand": { "type": "string", "minLength": 1 }, + "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 600 }, + "cwd": { "type": ["string", "null"], "pattern": "^[A-Za-z0-9._/-]+$" } + } + }), + "project.bootstrap" => json!({ + "type": "object", "required": ["cwd", "timeoutSeconds"], "additionalProperties": false, + "properties": { + "cwd": { "type": "string", "const": "game" }, "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 600 } } }), diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index db05d7596..03e41a663 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -321,6 +321,44 @@ pub(crate) fn resolve_project_command_spec_at( .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error)) } +/// Resolves the one privileged npm operation used to hydrate a DirectProject. +/// It intentionally bypasses the general command.exec npm allow-list: callers +/// must use the dedicated `project.bootstrap` action, which only accepts the +/// literal `npm install` in the project's `game` directory. +pub(crate) fn resolve_project_bootstrap_spec_at( + root: &Path, + timeout_seconds: u64, +) -> Result { + validate_project_root(root) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; + let cwd_relative = "game".to_string(); + let cwd = resolve_local_project_path(root, &cwd_relative) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; + validate_project_command_cwd_components(root, &cwd_relative) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; + if !(PROJECT_COMMAND_MIN_TIMEOUT_SECONDS..=PROJECT_COMMAND_MAX_TIMEOUT_SECONDS) + .contains(&timeout_seconds) + { + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Validation, + format!("project.bootstrap timeoutSeconds 必须在 {PROJECT_COMMAND_MIN_TIMEOUT_SECONDS}-{PROJECT_COMMAND_MAX_TIMEOUT_SECONDS} 之间"), + )); + } + let program = "npm".to_string(); + let (executable, safe_path) = resolve_project_command_executable(root, &program) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; + Ok(ProjectCommandSpec { + program, + executable, + safe_path, + arguments: vec!["install".to_string()], + cwd_relative, + cwd, + timeout_seconds, + verification_eligible: false, + }) +} + fn resolve_project_command_spec_inner( root: &Path, program: &str, @@ -1202,6 +1240,19 @@ pub(crate) fn prepare_project_command_launch_spec( (OsString::from("NO_PROXY"), OsString::new()), (OsString::from("PATH"), spec.safe_path.clone()), ]; + if spec + .arguments + .first() + .is_some_and(|argument| argument == "install") + { + for (name, value) in &mut environment { + match name.to_string_lossy().as_ref() { + "npm_config_offline" => *value = OsString::from("false"), + "HTTP_PROXY" | "HTTPS_PROXY" | "ALL_PROXY" => *value = OsString::new(), + _ => {} + } + } + } for name in ["SystemRoot", "PATHEXT", "RUSTUP_HOME"] { if let Some(value) = std::env::var_os(name) { environment.push((OsString::from(name), value)); @@ -1998,6 +2049,16 @@ pub(crate) async fn run_project_command_with_output_at( .await } +pub(crate) async fn run_project_bootstrap_command_at( + root: &Path, + timeout_seconds: u64, +) -> Result { + let spec = resolve_project_bootstrap_spec_at(root, timeout_seconds)?; + let launch = prepare_project_command_launch_spec(root, &spec)?; + let staged = stage_project_command_launch_spec(&spec, launch)?; + run_prepared_project_command_with_output_at(root, &spec, staged, None, || Ok(())).await +} + pub(crate) async fn run_prepared_project_command_with_output_at( root: &Path, spec: &ProjectCommandSpec, diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs index ed2a9cdf1..ae02e18b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs @@ -302,7 +302,19 @@ mod linux { cwd: &Path, environment: &[(OsString, OsString)], ) -> Result { - let metadata = CommandSandboxMetadata::enforced_linux(); + let mut metadata = CommandSandboxMetadata::enforced_linux(); + let network_enabled = executable + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| { + name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd") + }) + && arguments + .first() + .is_some_and(|argument| argument == "install"); + if network_enabled { + metadata.network = "enabled"; + } let bwrap = find_trusted_bwrap().map_err(|error| { CommandSandboxError::new( format!("command sandbox unavailable: {error}"), @@ -363,7 +375,7 @@ mod linux { )?; let fixed_system_read_only = collect_fixed_system_mounts(); - let launch = build_linux_bwrap_launch(LinuxSandboxPlan { + let mut launch = build_linux_bwrap_launch(LinuxSandboxPlan { bwrap, root, cwd, @@ -375,6 +387,7 @@ mod linux { external_read_only, fixed_system_read_only, }); + launch.metadata = metadata.clone(); run_project_mount_preflight(&launch).map_err(|error| { CommandSandboxError::new( format!("command sandbox project mount preflight 失败:{error}"), @@ -602,7 +615,19 @@ mod linux { fn build_linux_bwrap_launch(plan: LinuxSandboxPlan) -> CommandSandboxLaunch { let mut args = Vec::::new(); - push_namespace_arguments(&mut args); + push_namespace_arguments( + &mut args, + plan.executable + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| { + name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd") + }) + && plan + .arguments + .first() + .is_some_and(|argument| argument == "install"), + ); push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr")); for (target, destination) in &plan.merged_usr_links { push_option( @@ -673,7 +698,7 @@ mod linux { } } - fn push_namespace_arguments(args: &mut Vec) { + fn push_namespace_arguments(args: &mut Vec, share_network: bool) { for argument in [ "--die-with-parent", "--unshare-all", @@ -686,6 +711,9 @@ mod linux { ] { args.push(OsString::from(argument)); } + if share_network { + args.push(OsString::from("--share-net")); + } } fn push_option<'a, I>(args: &mut Vec, option: &str, values: I) @@ -829,7 +857,7 @@ mod linux { merged_usr_links: &[(OsString, PathBuf)], ) -> Result<(), String> { let mut args = Vec::::new(); - push_namespace_arguments(&mut args); + push_namespace_arguments(&mut args, false); push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr")); for (target, destination) in merged_usr_links { push_option( diff --git a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs index 2e267b28f..d8df54e84 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs @@ -39,6 +39,7 @@ pub(crate) const ISOLATED_AGENT_PRIVATE_MEMORY_SCHEMA_VERSION: &str = pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_COMMAND_IDS: &[&str] = &[ "project.verify", + "project.bootstrap", "project.git_commit", "command.exec", "command.start", @@ -59,6 +60,7 @@ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_COMMAND_IDS: &[&str] = &[ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS: &[&str] = &[ "project.verify", + "project.bootstrap", "project.git_commit", "command.exec", "command.start", diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 3cd6e4ac2..33e24e898 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -6,6 +6,7 @@ use std::io::{Seek, SeekFrom}; mod agent_db; mod asset_export; mod asset_rename; +mod bootstrap; mod checkpoint; mod conversation; mod export; @@ -23,6 +24,7 @@ mod write_lock; pub(crate) use agent_db::*; pub(crate) use asset_export::*; pub(crate) use asset_rename::*; +pub(crate) use bootstrap::*; pub(crate) use checkpoint::*; pub(crate) use conversation::*; pub(crate) use export::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/bootstrap.rs b/apps/ai-game-creator-shell/src-tauri/src/project/bootstrap.rs new file mode 100644 index 000000000..51ac834a7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/bootstrap.rs @@ -0,0 +1,113 @@ +use super::*; +use sha2::{Digest, Sha256}; + +const BOOTSTRAP_PACKAGE_MAX_BYTES: u64 = 512 * 1024; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProjectBootstrapResult { + pub(crate) status: String, + pub(crate) output: String, + pub(crate) package_sha256: String, + pub(crate) lock_sha256: Option, + pub(crate) log_path: String, + pub(crate) updated_at: u64, +} + +fn read_bootstrap_file(path: &Path, label: &str) -> Result, String> { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("读取 {label} 失败:{}: {error}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!("project.bootstrap 要求 {label} 是普通文件")); + } + if metadata.len() > BOOTSTRAP_PACKAGE_MAX_BYTES { + return Err(format!( + "project.bootstrap {label} 超过 {} 字节上限", + BOOTSTRAP_PACKAGE_MAX_BYTES + )); + } + prepare_game_creator_private_path_for_read(path, false, label)?; + fs::read(path).map_err(|error| format!("读取 {label} 失败:{error}")) +} + +pub(crate) async fn run_project_bootstrap_at( + root: &Path, + timeout_seconds: u64, +) -> Result { + validate_project_root(root)?; + let game = resolve_local_project_path(root, "game")?; + if !game.is_dir() { + return Err("project.bootstrap 只允许项目内 game 目录".to_string()); + } + let package = read_bootstrap_file(&game.join("package.json"), "game/package.json")?; + let package_json: serde_json::Value = serde_json::from_slice(&package) + .map_err(|error| format!("解析 game/package.json 失败:{error}"))?; + if let Some(manager) = package_json + .get("packageManager") + .and_then(serde_json::Value::as_str) + { + if !manager.trim().starts_with("npm@") && manager.trim() != "npm" { + return Err("project.bootstrap 当前只支持 npm packageManager".to_string()); + } + } + for lock_name in ["pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"] { + if game.join(lock_name).exists() { + return Err(format!("project.bootstrap 检测到非 npm 锁文件 {lock_name}")); + } + } + if !package_json + .get("scripts") + .is_some_and(serde_json::Value::is_object) + { + return Err("project.bootstrap 要求 game/package.json 包含 scripts 对象".to_string()); + } + let lock = match fs::symlink_metadata(game.join("package-lock.json")) { + Ok(_) => Some(read_bootstrap_file( + &game.join("package-lock.json"), + "game/package-lock.json", + )?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(format!("读取 game/package-lock.json 失败:{error}")), + }; + let command = crate::command_exec::run_project_bootstrap_command_at(root, timeout_seconds) + .await + .map_err(|error| error.to_string())?; + let completed = command.exit_code == Some(0) && !command.timed_out; + let status = if completed { "completed" } else { "failed" }; + let updated_at = unix_timestamp(); + let log_path = resolve_local_project_path(root, ".agent/logs/command.log")?; + let package_sha256 = format!("{:x}", Sha256::digest(&package)); + let lock_sha256 = lock + .as_ref() + .map(|bytes| format!("{:x}", Sha256::digest(bytes))); + let output = sanitize_project_verification_output(&command.output); + let line = format!( + "{updated_at} project.bootstrap status={} packageSha256={} lockSha256={} cwd=game\n{}\n", + status, + package_sha256, + lock_sha256.as_deref().unwrap_or("none"), + output + ); + append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志")?; + record_command_run( + root, + GameCreationAppCommandRunState { + command_id: "project.bootstrap".to_string(), + status: if completed { + GameCreationAppCommandRunStatus::Completed + } else { + GameCreationAppCommandRunStatus::Failed + }, + output: output.clone(), + log_path: ".agent/logs/command.log".to_string(), + updated_at, + }, + )?; + Ok(ProjectBootstrapResult { + status: status.to_string(), + output, + package_sha256, + lock_sha256, + log_path: ".agent/logs/command.log".to_string(), + updated_at, + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs index 12a677216..3a358c46e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs @@ -121,6 +121,7 @@ pub(crate) struct ProjectVerificationSpec { pub(crate) program: String, pub(crate) arguments: Vec, pub(crate) timeout_seconds: u64, + pub(crate) cwd_relative: String, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -129,6 +130,7 @@ pub(crate) struct ProjectVerificationResult { pub(crate) script: String, pub(crate) expected_command: String, pub(crate) package_manager: String, + pub(crate) cwd_relative: String, pub(crate) status: String, pub(crate) exit_code: Option, pub(crate) timed_out: bool, @@ -290,9 +292,38 @@ pub(crate) fn resolve_project_verification_spec_at( script: &str, expected_command: &str, timeout_seconds: u64, +) -> Result { + resolve_project_verification_spec_with_cwd_at( + root, + script, + expected_command, + timeout_seconds, + ".", + ) +} + +pub(crate) fn resolve_project_verification_spec_with_cwd_at( + root: &Path, + script: &str, + expected_command: &str, + timeout_seconds: u64, + cwd_relative: &str, ) -> Result { validate_project_root(root)?; - ensure_project_verification_has_no_project_npmrc(root)?; + let cwd_relative = if cwd_relative.trim().is_empty() || cwd_relative.trim() == "." { + ".".to_string() + } else { + normalize_relative_path(cwd_relative)? + }; + let package_root = if cwd_relative == "." { + root.to_path_buf() + } else { + resolve_local_project_path(root, &cwd_relative)? + }; + if !package_root.is_dir() { + return Err("project.verify cwd 必须是项目内普通目录".to_string()); + } + ensure_project_verification_has_no_project_npmrc(&package_root)?; let script = script.trim(); if script.chars().count() > PROJECT_VERIFICATION_SCRIPT_MAX_CHARS { return Err(format!( @@ -323,7 +354,7 @@ pub(crate) fn resolve_project_verification_spec_at( )); } - let package_path = root.join("package.json"); + let package_path = package_root.join("package.json"); let metadata = fs::symlink_metadata(&package_path).map_err(|error| { format!( "读取 package.json 失败:{}: {error}", @@ -348,7 +379,7 @@ pub(crate) fn resolve_project_verification_spec_at( })?; let package: serde_json::Value = serde_json::from_str(&package_content) .map_err(|error| format!("解析 package.json 失败:{error}"))?; - let package_manager = project_verification_package_manager_at(root, &package)?; + let package_manager = project_verification_package_manager_at(&package_root, &package)?; let actual_command = package .get("scripts") .and_then(serde_json::Value::as_object) @@ -360,6 +391,20 @@ pub(crate) fn resolve_project_verification_spec_at( "package.json 中的 {script} 脚本已变化,请重新读取后再确认执行" )); } + if script == "build" && cwd_relative == "game" { + let modules_path = package_root.join("node_modules"); + let modules_metadata = fs::symlink_metadata(&modules_path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + "project.verify build 前缺少 game/node_modules;请先执行 project.bootstrap" + .to_string() + } else { + format!("project.verify 检查 game/node_modules 失败:{error}") + } + })?; + if modules_metadata.file_type().is_symlink() || !modules_metadata.is_dir() { + return Err("project.verify build 前的 game/node_modules 不是普通目录;请重新执行 project.bootstrap".to_string()); + } + } Ok(ProjectVerificationSpec { script: script.to_string(), @@ -373,6 +418,7 @@ pub(crate) fn resolve_project_verification_spec_at( script.to_string(), ], timeout_seconds, + cwd_relative, }) } @@ -485,10 +531,21 @@ async fn run_project_verification_process( where F: FnOnce() -> Result<(), String>, { - ensure_project_verification_has_no_project_npmrc(root) + let package_root = if spec.cwd_relative == "." { + root.to_path_buf() + } else { + resolve_local_project_path(root, &spec.cwd_relative) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))? + }; + ensure_project_verification_has_no_project_npmrc(&package_root) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; - let command_spec = - resolve_project_command_spec_at(root, "npm", &spec.arguments, ".", spec.timeout_seconds)?; + let command_spec = resolve_project_command_spec_at( + root, + "npm", + &spec.arguments, + &spec.cwd_relative, + spec.timeout_seconds, + )?; let launch = prepare_project_command_launch_spec(root, &command_spec)?; let launch_metadata = launch.clone(); let staged = stage_project_command_launch_spec(&command_spec, launch)?; @@ -620,9 +677,14 @@ pub(crate) async fn run_project_verification_at( expected_command: &str, timeout_seconds: u64, ) -> Result { - run_project_verification_with_commit_at(root, script, expected_command, timeout_seconds, || { - Ok(()) - }) + run_project_verification_with_commit_at( + root, + script, + expected_command, + timeout_seconds, + ".", + || Ok(()), + ) .await } @@ -631,15 +693,21 @@ pub(crate) async fn run_project_verification_with_commit_at( script: &str, expected_command: &str, timeout_seconds: u64, + cwd_relative: &str, durable_commit: F, ) -> Result where F: FnOnce() -> Result<(), String>, { - let spec = - resolve_project_verification_spec_at(root, script, expected_command, timeout_seconds)?; + let spec = resolve_project_verification_spec_with_cwd_at( + root, + script, + expected_command, + timeout_seconds, + cwd_relative, + )?; let started_at = std::time::Instant::now(); - let process = match run_project_verification_process(root, &spec, durable_commit).await { + let mut process = match run_project_verification_process(root, &spec, durable_commit).await { Ok(process) => process, Err(error) if error.needs_reconciliation() => { return Err(format!("project.verify 执行状态需要人工核对:{error}")); @@ -658,7 +726,18 @@ where }, }; let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); - let completed = !process.timed_out && process.exit_code == Some(0); + let mut completed = !process.timed_out && process.exit_code == Some(0); + if completed && spec.script == "build" && spec.cwd_relative == "game" { + let dist_entry = root.join("game").join("dist").join("index.html"); + completed = fs::symlink_metadata(&dist_entry) + .is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink()); + if !completed { + process.output = format!( + "{}\nproject.verify build 成功但缺少 game/dist/index.html", + process.output + ); + } + } let status = if completed { "completed" } else { "failed" }; let command_id = format!("project.verify.{}", spec.script); let updated_at = unix_timestamp(); @@ -703,6 +782,7 @@ where script: spec.script, expected_command: spec.expected_command, package_manager: spec.package_manager, + cwd_relative: spec.cwd_relative, status: status.to_string(), exit_code: process.exit_code, timed_out: process.timed_out, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs index fa67942f9..3dd791743 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs @@ -860,14 +860,11 @@ pub(crate) fn acquire_project_write_lock_failure( } } } - if crate::agent::autonomous_game_build_root_run_active_at(root) - && project_write_lock_is_owned_by_current_process(&path) - { - // The autonomous game-build lane intentionally permits - // parallel specialist actions. If the durable lock belongs - // to this very process, contention is an in-process overlap, - // not another application editing the project. Return an - // advisory guard and leave the real lock untouched. + if project_write_lock_is_owned_by_current_process(&path) { + // A project lock is the client-use lock. Nested calls in + // the same client process must reuse that ownership instead + // of waiting on their own durable marker. Cross-process + // contenders still take the normal retryable path. return Ok(ProjectWriteLock { path, content: String::new(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index a37bc05cb..7e39fccbe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -5611,12 +5611,14 @@ fn local_project_checkpoint_diff_restore_and_index_are_recorded() { } #[test] -fn project_write_lock_rejects_parallel_writer_and_releases_on_drop() { +fn project_write_lock_reuses_same_process_owner_and_releases_on_drop() { let root = unique_project_path(); let first = acquire_project_write_lock(&root, "file.write").expect("first lock"); - let error = acquire_project_write_lock(&root, "file.delete").expect_err("second lock fails"); - assert!(error.contains("项目正在被其他写操作占用")); + let nested = acquire_project_write_lock(&root, "file.delete") + .expect("same process must reuse the client project lock"); + drop(nested); + assert!(root.join(PROJECT_WRITE_LOCK_PATH).exists()); drop(first); acquire_project_write_lock(&root, "file.delete").expect("lock released"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs index ca8c9da94..e532e76b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs @@ -389,7 +389,23 @@ fn collect_native_tool_absolute_path_findings( } } } + "project.bootstrap" => { + collect_native_string_field( + root, + input, + "cwd", + &format!("{input_pointer}/cwd"), + findings, + ); + } "project.verify" => { + collect_native_string_field( + root, + input, + "cwd", + &format!("{input_pointer}/cwd"), + findings, + ); collect_native_string_field( root, input, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 3aece9802..d8d0ad655 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -5063,7 +5063,7 @@ export function App({ ...current, { role: 'assistant', - text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', + text: '当前仅支持确认 project.index、project.status、project.bootstrap、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', }, ]); return; @@ -5126,7 +5126,7 @@ export function App({ ...current, { role: 'assistant', - text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', + text: '当前仅支持确认 project.index、project.status、project.bootstrap、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', }, ]); return; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 913ecac2f..c9061de45 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -93,6 +93,7 @@ export function WorkspaceLauncherShell({ startGameFromApprovedGdd, createHomeDraftAutomatically, openProject, + homeCreationBusy, } = homeProject; const switchedToGameRuntime = gameRuntimeSwitch !== null && @@ -522,7 +523,7 @@ export function WorkspaceLauncherShell({ onStatusChange={setStatus} recentProjectRows={recentProjectRows} onCreateDraftAutomatically={createHomeDraftAutomatically} - creationBusy={homeProject.projectAction === 'creating'} + creationBusy={homeCreationBusy} onProjectsOpen={() => setLauncherView('projects')} onProjectOpen={(path) => { setProjectPath(path); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 8ecc89fb7..56e0413ca 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -26,6 +26,11 @@ import type { TauriInvoke, UploadLocalAssetResult, } from '../../app/types'; +import { + type ClientOperation, + createClientOperation, + transitionClientOperation, +} from '../../services/clientOperation'; import type { HomeAttachmentDraft, HomeCreationType, @@ -117,6 +122,21 @@ export function useHomeProjectCreation({ (state) => state.reset, ); const approvedGddStartInFlightRef = useRef(false); + const [homeCreationOperation, setHomeCreationOperation] = + useState | null>(null); + const homeCreationOperationRef = useRef(homeCreationOperation); + homeCreationOperationRef.current = homeCreationOperation; + + function homeCreationIsBusy() { + const phase = homeCreationOperationRef.current?.phase; + return phase === 'network' || phase === 'runner' || phase === 'project'; + } /** * 进项目流程的代次。 * @@ -644,13 +664,19 @@ export function useHomeProjectCreation({ startMode: ProjectStartMode, options: { suggestName: boolean }, ) { - if (projectActionRef.current) { + if (projectActionRef.current || homeCreationIsBusy()) { return '已有项目操作进行中,请稍候'; } const invoke = resolveTauriInvoke(); if (!invoke) { throw new Error('需要在陶泥儿客户端内运行'); } + const operation = createClientOperation( + 'home-create', + { draft, startMode }, + { deadlineMs: null, cancellable: false }, + ); + setHomeCreationOperation(transitionClientOperation(operation, 'network')); // This action is owned by WorkspaceLauncher rather than HomeView. The // launcher survives navigation, so unmounting the home page cannot release // the guard while project creation or first-turn import is still running. @@ -668,6 +694,11 @@ export function useHomeProjectCreation({ planning: startMode === 'planning', }, ); + setHomeCreationOperation( + transitionClientOperation(operation, 'project', { + scope: { projectPath: result.projectPath }, + }), + ); try { await enterCreatedHomeProject( invoke, @@ -677,15 +708,30 @@ export function useHomeProjectCreation({ draft.attachments, startMode, ); + setHomeCreationOperation( + transitionClientOperation(operation, 'success', { + scope: { projectPath: result.projectPath }, + }), + ); setStatus('已创建工作区,正在开始智能创作'); return '已创建工作区并进入项目开发'; } catch (error) { + setHomeCreationOperation( + transitionClientOperation(operation, 'retryable-failure', { + scope: { projectPath: result.projectPath }, + }), + ); const message = `工作区已创建;首条需求投递失败:${ error instanceof Error ? error.message : String(error) }`; setStatus(message); throw new Error(message); } + } catch (error) { + setHomeCreationOperation( + transitionClientOperation(operation, 'retryable-failure'), + ); + throw error; } finally { if (projectActionRef.current === 'creating') { projectActionRef.current = null; @@ -802,6 +848,8 @@ export function useHomeProjectCreation({ setAgentResults, projectAction, projectBusy: projectAction !== null, + homeCreationOperation, + homeCreationBusy: homeCreationIsBusy(), pendingNonEmptyProject, resetLauncherHomeDraft, startGameFromApprovedGdd, diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index a529ef10b..54c4c27e8 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -20,6 +20,11 @@ import { getClientServerBaseUrl, readClientHttpResponseText, } from './clientHttp'; +import { + type ClientOperation, + createClientOperation, + transitionClientOperation, +} from './clientOperation'; const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; @@ -55,6 +60,14 @@ export function clearStoredAuthAccessToken() { } const clientAuthRefreshPromises = new Map>(); +const clientAuthRefreshOperations = new Map< + string, + ClientOperation<'auth-refresh', { apiBaseUrl: string }> +>(); + +export function getClientAuthRefreshOperation(apiBaseUrl: string) { + return clientAuthRefreshOperations.get(apiBaseUrl) ?? null; +} const CLIENT_AUTH_NETWORK_ERROR_MESSAGE = '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; @@ -187,6 +200,15 @@ export async function refreshClientAuthAccessToken( ) { const current = clientAuthRefreshPromises.get(apiBaseUrl); if (current) return current; + const operation = createClientOperation( + 'auth-refresh', + { apiBaseUrl }, + { scope: { apiBaseUrl }, deadlineMs: 15_000 }, + ); + clientAuthRefreshOperations.set( + apiBaseUrl, + transitionClientOperation(operation, 'network'), + ); const refreshPromise = requestAuthJson( '/api/auth/refresh', { method: 'POST' }, @@ -194,9 +216,20 @@ export async function refreshClientAuthAccessToken( { skipAuth: true, apiBaseUrl }, ) .then((response) => { + clientAuthRefreshOperations.set( + apiBaseUrl, + transitionClientOperation(operation, 'success'), + ); setStoredAuthAccessToken(response.token); return response.token; }) + .catch((error) => { + clientAuthRefreshOperations.set( + apiBaseUrl, + transitionClientOperation(operation, 'retryable-failure'), + ); + throw error; + }) .finally(() => { if (clientAuthRefreshPromises.get(apiBaseUrl) === refreshPromise) { clientAuthRefreshPromises.delete(apiBaseUrl); diff --git a/apps/ai-game-creator-shell/src/services/clientOperation.ts b/apps/ai-game-creator-shell/src/services/clientOperation.ts new file mode 100644 index 000000000..d4d03bf79 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/clientOperation.ts @@ -0,0 +1,105 @@ +export type ClientOperationPhase = + | 'idle' + | 'network' + | 'runner' + | 'project' + | 'success' + | 'retryable-failure' + | 'unknown'; + +export type ClientOperationScope = { + projectPath?: string; + apiBaseUrl?: string; + sessionGeneration?: number; +}; + +export type ClientOperation< + TKind extends string = string, + TPayload = unknown, +> = { + operationId: string; + requestId: string; + kind: TKind; + phase: ClientOperationPhase; + startedAt: number; + deadlineAt: number | null; + scope: ClientOperationScope; + payload: TPayload; + cancellable: boolean; + cancelled: boolean; +}; + +let operationSequence = 0; + +function randomOperationPart() { + try { + return crypto.randomUUID(); + } catch { + operationSequence += 1; + return `${Date.now().toString(36)}-${operationSequence.toString(36)}`; + } +} + +export function createClientOperation( + kind: TKind, + payload: TPayload, + options: { + scope?: ClientOperationScope; + deadlineMs?: number | null; + cancellable?: boolean; + } = {}, +): ClientOperation { + const startedAt = Date.now(); + const deadlineMs = options.deadlineMs ?? null; + return { + operationId: `op-${randomOperationPart()}`, + requestId: `req-${randomOperationPart()}`, + kind, + phase: 'idle', + startedAt, + deadlineAt: + deadlineMs === null ? null : startedAt + Math.max(1, deadlineMs), + scope: { ...options.scope }, + payload, + cancellable: options.cancellable ?? true, + cancelled: false, + }; +} + +export function transitionClientOperation( + operation: ClientOperation, + phase: ClientOperationPhase, + patch: Partial< + Pick, 'scope' | 'payload'> + > = {}, +) { + return { + ...operation, + ...patch, + scope: patch.scope + ? { ...operation.scope, ...patch.scope } + : operation.scope, + phase, + }; +} + +export function cancelClientOperation( + operation: ClientOperation, +) { + return { ...operation, phase: 'unknown' as const, cancelled: true }; +} + +export function isCurrentClientOperation( + operation: ClientOperation | null | undefined, + operationId: string, +) { + return Boolean( + operation && operation.operationId === operationId && !operation.cancelled, + ); +} + +export function clientOperationCanRetry( + operation: ClientOperation | null | undefined, +) { + return operation?.phase === 'retryable-failure'; +} diff --git a/apps/ai-game-creator-shell/src/services/platformSession.ts b/apps/ai-game-creator-shell/src/services/platformSession.ts index b5402ba5d..ba708b774 100644 --- a/apps/ai-game-creator-shell/src/services/platformSession.ts +++ b/apps/ai-game-creator-shell/src/services/platformSession.ts @@ -6,6 +6,11 @@ import { refreshClientAuthAccessToken, } from './clientAuth'; import { getClientServerBaseUrl } from './clientHttp'; +import { + type ClientOperation, + createClientOperation, + transitionClientOperation, +} from './clientOperation'; const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; @@ -34,12 +39,20 @@ let committedPlatformSession: CommittedPlatformSession | null = null; let desiredPlatformSession: CommittedPlatformSession | null = null; let platformSessionRefreshPromise: Promise | null = null; +let platformSessionOperation: ClientOperation< + 'auth-transition', + { userId: string | null } +> | null = null; let platformSessionNativeMutationTail: Promise = Promise.resolve(); const platformSessionRefreshListeners = new Set(); const platformSessionGenerationListeners = new Set(); +export function getPlatformSessionOperation() { + return platformSessionOperation; +} + function restoreCommittedAccessToken() { if (committedPlatformSession?.accessToken) { window.localStorage.setItem( @@ -259,15 +272,44 @@ export async function commitAuthenticatedPlatformSession( if (!accessToken) { throw new Error('陶泥儿登录凭据缺失,请重新登录'); } + const operation = createClientOperation( + 'auth-transition', + { userId: user.id }, + { + scope: { apiBaseUrl, sessionGeneration: expectedGeneration }, + deadlineMs: 45_000, + cancellable: false, + }, + ); + platformSessionOperation = transitionClientOperation(operation, 'runner'); return enqueuePlatformSessionNativeMutation(async () => { - const session = await commitPlatformSession( - user, - accessToken, - apiBaseUrl, - expectedGeneration, - ); - if (!session) return null; - return session.generation; + try { + const session = await commitPlatformSession( + user, + accessToken, + apiBaseUrl, + expectedGeneration, + ); + if (!session) { + platformSessionOperation = transitionClientOperation( + operation, + 'unknown', + ); + return null; + } + platformSessionOperation = transitionClientOperation( + operation, + 'success', + { scope: { sessionGeneration: session.generation } }, + ); + return session.generation; + } catch (error) { + platformSessionOperation = transitionClientOperation( + operation, + 'retryable-failure', + ); + throw error; + } }); } @@ -375,30 +417,64 @@ export function subscribePlatformSessionGeneration( } export async function clearCommittedPlatformSession(generation: number) { + const operation = createClientOperation( + 'auth-transition', + { userId: null }, + { + scope: { sessionGeneration: generation }, + deadlineMs: 45_000, + cancellable: false, + }, + ); + platformSessionOperation = transitionClientOperation(operation, 'runner'); return enqueuePlatformSessionNativeMutation(async () => { - if (platformAuthGeneration !== generation) { - await reconcileNativePlatformSessionToCurrentAuthority(); - return; - } - desiredPlatformSession = null; - const nativeGeneration = await reserveNativePlatformSessionGeneration(); try { - await clearNativePlatformSession(nativeGeneration); - } catch { - if (platformAuthGeneration === generation) { - desiredPlatformSession = null; + if (platformAuthGeneration !== generation) { + await reconcileNativePlatformSessionToCurrentAuthority(); + platformSessionOperation = transitionClientOperation( + operation, + 'unknown', + ); + return; } - await reconcileNativePlatformSessionToCurrentAuthority(); - return; + desiredPlatformSession = null; + const nativeGeneration = await reserveNativePlatformSessionGeneration(); + try { + await clearNativePlatformSession(nativeGeneration); + } catch { + if (platformAuthGeneration === generation) { + desiredPlatformSession = null; + } + await reconcileNativePlatformSessionToCurrentAuthority(); + platformSessionOperation = transitionClientOperation( + operation, + 'retryable-failure', + ); + return; + } + if (platformAuthGeneration !== generation) { + await reconcileNativePlatformSessionToCurrentAuthority(); + platformSessionOperation = transitionClientOperation( + operation, + 'unknown', + ); + return; + } + committedPlatformSession = null; + desiredPlatformSession = null; + restoreCommittedAccessToken(); + notifyPlatformSessionGeneration(); + platformSessionOperation = transitionClientOperation( + operation, + 'success', + ); + } catch (error) { + platformSessionOperation = transitionClientOperation( + operation, + 'retryable-failure', + ); + throw error; } - if (platformAuthGeneration !== generation) { - await reconcileNativePlatformSessionToCurrentAuthority(); - return; - } - committedPlatformSession = null; - desiredPlatformSession = null; - restoreCommittedAccessToken(); - notifyPlatformSessionGeneration(); }); } @@ -409,6 +485,7 @@ export function resetPlatformSessionStateForTests() { committedPlatformSession = null; desiredPlatformSession = null; platformSessionRefreshPromise = null; + platformSessionOperation = null; platformSessionNativeMutationTail = Promise.resolve(); platformSessionRefreshListeners.clear(); platformSessionGenerationListeners.clear(); diff --git a/apps/ai-game-creator-shell/tests/clientApi.test.ts b/apps/ai-game-creator-shell/tests/clientApi.test.ts index 338768a99..7b31137a0 100644 --- a/apps/ai-game-creator-shell/tests/clientApi.test.ts +++ b/apps/ai-game-creator-shell/tests/clientApi.test.ts @@ -6,7 +6,10 @@ import { requestClientApi, setStoredAuthAccessToken, } from '../src/services/clientApi'; -import { refreshClientAuthAccessToken } from '../src/services/clientAuth'; +import { + getClientAuthRefreshOperation, + refreshClientAuthAccessToken, +} from '../src/services/clientAuth'; import { beginPlatformSessionTransition, commitAuthenticatedPlatformSession, @@ -169,6 +172,10 @@ it('响应体卡住超时后,下一次续期会重新发起请求', async () = const firstAssertion = expect(first).rejects.toThrow(); await vi.advanceTimersByTimeAsync(15_000); await firstAssertion; + expect(getClientAuthRefreshOperation('http://localhost:3000')).toMatchObject({ + kind: 'auth-refresh', + phase: 'retryable-failure', + }); const second = refreshClientAuthAccessToken('http://localhost:3000'); const secondAssertion = expect(second).rejects.toThrow(); diff --git a/apps/ai-game-creator-shell/tests/clientOperation.test.ts b/apps/ai-game-creator-shell/tests/clientOperation.test.ts new file mode 100644 index 000000000..ccb7d8b9b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/clientOperation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { + cancelClientOperation, + clientOperationCanRetry, + createClientOperation, + isCurrentClientOperation, + transitionClientOperation, +} from '../src/services/clientOperation'; + +describe('AGC client operation contract', () => { + it('keeps operation/request identity and deadline across phase transitions', () => { + const operation = createClientOperation( + 'home-create', + { prompt: '做一个游戏' }, + { scope: { projectPath: 'C:\\games\\demo' }, deadlineMs: 15_000 }, + ); + const network = transitionClientOperation(operation, 'network'); + const project = transitionClientOperation(network, 'project', { + scope: { projectPath: 'C:\\games\\created' }, + }); + + expect(project.operationId).toBe(operation.operationId); + expect(project.requestId).toBe(operation.requestId); + expect(project.deadlineAt).toBe(operation.startedAt + 15_000); + expect(project.scope.projectPath).toBe('C:\\games\\created'); + expect(project.cancellable).toBe(true); + expect(isCurrentClientOperation(project, operation.operationId)).toBe(true); + }); + + it('marks cancellation as stale and does not make it retryable', () => { + const operation = createClientOperation('auth-refresh', null); + const failed = transitionClientOperation(operation, 'retryable-failure'); + expect(clientOperationCanRetry(failed)).toBe(true); + const cancelled = cancelClientOperation(failed); + expect(cancelled.cancelled).toBe(true); + expect(isCurrentClientOperation(cancelled, operation.operationId)).toBe( + false, + ); + expect(clientOperationCanRetry(cancelled)).toBe(false); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index fb4534a86..4edf0f708 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -36,26 +36,30 @@ const ownedBackend = async () => ({ }); function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) { + const instanceId = 'fixture-instance'; + const service = (url: string) => ({ + status: 'running', + url, + repoRoot: resolve('.'), + instanceId, + }); return { schemaVersion: spacetimeDataDir ? 2 : 1, + repoRoot: resolve('.'), + instanceId, database: expectedDatabase, updatedAt: '', ...(spacetimeDataDir ? { spacetimeDataDir } : {}), services: { 'api-server': { - status: 'running', - url: 'http://127.0.0.1:8082', + ...service('http://127.0.0.1:8082'), }, spacetime: { - status: 'running', - url: 'http://127.0.0.1:3101', + ...service('http://127.0.0.1:3101'), }, ...(includeBgfilterWorker ? { - 'bgfilter-worker': { - status: 'running', - url: 'http://127.0.0.1:8083', - }, + 'bgfilter-worker': service('http://127.0.0.1:8083'), } : {}), }, @@ -113,6 +117,30 @@ describe('AI 游戏创作配套后端复用门禁', () => { expect(matching.bgfilterWorkerUrl).toBe('http://127.0.0.1:8083'); }); + test('旧状态缺少 repoRoot 或 instanceId 时拒绝复用', () => { + const state = backendState(expectedDataDir); + delete state.repoRoot; + delete state.instanceId; + for (const service of Object.values(state.services)) { + if (service) { + delete service.repoRoot; + delete service.instanceId; + } + } + const targets = resolveBackendTargetsFromState(state, { + requireAgcBackend: true, + expectedDatabase, + expectedSpacetimeDataDir: expectedDataDir, + }); + + expect(targets.hasMatchingDatabase).toBe(true); + expect(targets.hasMatchingDataDir).toBe(true); + expect(targets.hasMatchingRepoRoot).toBe(false); + expect(targets.hasMatchingInstance).toBe(false); + expect(targets.hasMatchingBackend).toBe(false); + expect(targets.apiUrl).toBe(''); + }); + test('worker 缺失或未 ready 时不允许复用后端', async () => { const isReady = vi.fn(async (_url: string) => true); diff --git a/apps/ai-game-creator-shell/vite.config.ts b/apps/ai-game-creator-shell/vite.config.ts index 71b012d81..6e68153ba 100644 --- a/apps/ai-game-creator-shell/vite.config.ts +++ b/apps/ai-game-creator-shell/vite.config.ts @@ -63,6 +63,8 @@ export default defineConfig({ JSON.stringify({ schemaVersion: 1, app: 'ai-game-creator-shell', + repoRoot, + processId: process.pid, port: server.config.server.port, apiTarget, }), diff --git a/docs/README.md b/docs/README.md index da3d178e0..b50d9c401 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,6 +31,7 @@ - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 - [AGC 异步操作可恢复闭环](./【技术方案】AGC异步操作可恢复闭环-2026-09-14.md):认证响应体、最近项目检查和首页自动创建的超时、逐项恢复与跨页防重合同。 +- [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。 - [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):新单 Agent 策划会话、GDD 策略、未来 MCP/Skill 兼容插槽、阶段任务与退役验收合同。 - [DirectProject Codex 原始历史与异常恢复](<./technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md>):原始 Responses item 持久化、线程注入与异常回合收尾。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject Phaser迁移闭环-2026-09-14.md b/docs/project-memory/plans/【实施计划】DirectProject Phaser迁移闭环-2026-09-14.md new file mode 100644 index 000000000..927385da2 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】DirectProject Phaser迁移闭环-2026-09-14.md @@ -0,0 +1,26 @@ +Version: 1.0 +Status: implemented; real-project runtime smoke pending +Date: 2026-09-14 +Parent Milestone: docs/project-memory/plans/【里程碑】DirectProject Phaser迁移闭环-2026-09-14.md + +## 修改顺序 + +1. 扩展 shared command contract 与原生工具 schema。 +2. 增加 `project.bootstrap` 的安全校验、npm install 执行、审计与分发。 +3. 扩展 `project.verify` 的 cwd/package/dist 门禁和提示词。 +4. 更新 DirectProject 迁移工程指导与前端命令显示。 +5. 补充定向测试并运行 typecheck、Rust 检查、编码与 diff 检查。 + +## 验证命令 + +- `npm run ai-game-creator-shell:typecheck` +- `cargo test -p ai-game-creator-shell project_bootstrap` +- `cargo test -p ai-game-creator-shell project_verify` +- `npm run check:encoding` +- `git diff --check` + +## 风险与回滚 + +- 旧 `project.verify` 输入不带 cwd 时保持项目根行为。 +- bootstrap 不修改 command.exec 的 npm 白名单;失败关闭并返回可诊断错误。 +- 若 sandbox 网络能力无法在当前平台验证,仅保留实现与单测证据,不宣称真实安装通过。 diff --git a/docs/project-memory/plans/【实施计划】项目客户端占用锁收敛-2026-09-14.md b/docs/project-memory/plans/【实施计划】项目客户端占用锁收敛-2026-09-14.md new file mode 100644 index 000000000..f1ffb1b07 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】项目客户端占用锁收敛-2026-09-14.md @@ -0,0 +1,34 @@ +# 【实施计划】项目客户端占用锁收敛-2026-09-14 + +Version: 1 +Status: in-progress +Date: 2026-09-14 +Milestone: `【里程碑】项目客户端占用锁收敛-2026-09-14.md` + +## 代码边界 + +- `apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs` +- `apps/ai-game-creator-shell/src-tauri/src/runner/project_owner.rs` +- `apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs` +- 锁恢复、Runtime 写入和 Runner owner 定向测试 + +## 修改顺序 + +1. 统一同进程嵌套调用的项目锁语义,禁止自等待。 +2. 盘点并迁移 Runner 的项目级 owner 文件到统一锁,保留诊断投影与跨 boot 恢复。 +3. 删除重复项目级锁路径及其专属调用,保留底层原子写和 Git 锁。 +4. 补齐同进程重入、跨进程占用、崩溃恢复和锁释放测试。 + +## 验证命令 + +- `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` +- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_write_lock_reuses_same_process_owner_and_releases_on_drop --no-default-features` +- Runner owner 与 response stream 相关定向测试 +- `npm run check:encoding` +- `git diff --check` + +## 风险与回滚点 + +- Runner 与 GUI 可能是不同进程;统一锁前必须验证同一客户端不会互相阻塞。 +- 旧 `.agent/runtime/execution-owner.lock` 残留需要按 PID/启动身份安全回收,不能直接删除。 +- 若跨 boot 恢复或 GUI/Runner 联动回归,回滚统一路径迁移,保留已验证的同进程重入修复。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject Phaser迁移闭环-2026-09-14.md b/docs/project-memory/plans/【里程碑】DirectProject Phaser迁移闭环-2026-09-14.md new file mode 100644 index 000000000..54114ae05 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】DirectProject Phaser迁移闭环-2026-09-14.md @@ -0,0 +1,28 @@ +Version: 1.0 +Status: implemented; real-project runtime smoke pending +Date: 2026-09-14 +Parent Spec: docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md + +## 目标 + +为 DirectProject 建立单 HTML 到 Phaser 4 + Vite 的可运行迁移闭环:项目文件真实落盘、受控依赖初始化、`game` 子目录构建验证、`game/dist` 预览与桌面/移动验收。 + +## 边界 + +- DirectProject 迁移请求固定使用 `workspaceMode=DirectProject`,不再通过 JSON Generator 的 `gameHtml` 协议生成 npm 工程。 +- `project.bootstrap` 只允许在项目内 `game` 目录执行无参数 `npm install`,读取并记录 package/lock 指纹。 +- `project.verify` 支持相对 `cwd`,构建验证必须读取该目录 package.json 的原始脚本并确认 `dist/index.html`。 +- 预览继续只读取构建产物;不实现自动迁移未知游戏玩法语义或真实 Provider 生成。 + +## 验收标准 + +1. 命令契约、原生工具 schema、运行时分发、权限面板和提示词包含 `project.bootstrap`。 +2. bootstrap 拒绝越界 cwd、非 `game` 目录、参数注入和缺失/非法 package 文件,并在成功后返回依赖指纹。 +3. `project.verify` 能在 `cwd=game` 执行原样 `npm run build`,成功条件包含 `game/dist/index.html`。 +4. DirectProject 提示明确要求 Phaser 4.2.1、`import Phaser from 'phaser'`、Vite 输出 `game/dist` 及完整玩法迁移。 +5. 定向 Rust/TypeScript 测试、类型检查、编码检查和 `git diff --check` 通过;无法运行真实 Provider/浏览器时明确记录。 + +## 依赖 + +- 现有 project command sandbox、verification gate、preview resolver 和 command run/audit 持久化。 +- 用户选择的真实项目目录必须包含 `game/index.html` 才能执行实际迁移。 diff --git a/docs/project-memory/plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md b/docs/project-memory/plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md new file mode 100644 index 000000000..ed1c643a2 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md @@ -0,0 +1,28 @@ +# 【里程碑】项目客户端占用锁收敛-2026-09-14 + +Version: 1 +Status: in-progress +Date: 2026-09-14 +Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` + +## 目标 + +项目只保留一个面向客户端占用的项目级跨进程锁,防止多个客户端同时打开同一项目;同一客户端进程内的嵌套调用复用既有项目锁,不因自身持锁进入等待。 + +## 边界 + +- 项目客户端占用锁与项目写入调用的职责统一,跨进程竞争仍返回占用语义。 +- Agent DB、session lane、manifest 原子写和 Git 自身的底层一致性机制不在本里程碑删除范围内。 +- 不改变项目 revision、权限、幂等、恢复和数据格式合同。 + +## 验收标准 + +- 同一进程内嵌套取得项目锁立即返回 advisory guard,不等待、不删除真实持有者锁。 +- 不同进程持有项目锁时仍保持占用失败与残留回收判据。 +- 客户端项目占用入口与 Runtime 写入入口不会各自维护第二个项目级锁文件。 +- 锁释放后下一客户端可重新取得锁。 +- 定向 Rust 锁测试、`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。 + +## 未决事项 + +- Runner 的 `execution-owner.lock` 如何迁移到统一客户端占用锁,需要补充跨进程启动、恢复和诊断测试后再落地。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 50e67a1b8..a72200ff0 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5495,3 +5495,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **真机判据**:点进任一栏目(或展开「所有资源」)时,**每一张**卡片都从它那一摞的位置/尺寸位移并缩放到自己的位置,而不只是总览里那 3 张;同一栏目里不同类型的两摞各自飞各自的卡。`prefers-reduced-motion: reduce` 下仍不播放动画(口径未改)。 - **已知未覆盖**:① 子画布内直接切到另一个栏目(分页画布换栏目)时目标栏目的卡片在该次 `begin` 时未渲染、拿不到任何 First,整段转场仍按旧口径跳过(`play` 的 `!captured.entries.size` 早退),本次未改;② 真机动画观感由浏览器渲染,jsdom 只覆盖几何与调用契约,需要按上面那条判据人工确认一次。 - **关联**:`apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts`(`begin` 的堆锚点 / `syncNodes` 的合成 First)、`resourceBookLayout.ts`(`allOpen` 列号)、`index.tsx`(宿主 `data-resource-book-stack-*`)、`apps/ai-game-creator-shell/tests/{resourceBookController,resourceBookLayout}.test.ts`、`tests/appSurface/project-development.suite.ts`、`docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`。 + +## 稳定版 AGC 复用开发栈必须核对 instance identity + +- 现象:端口和 `/healthz` 都正常,但 AGC 连接了另一个 worktree 的 API、SpacetimeDB 或旧 Vite,表现为登录、项目列表、Runner 状态与当前代码不一致。 +- 原因:健康检查只能证明“有服务响应”,不能证明服务属于当前工作树;旧 `.app/dev-stack.json` 可能没有当前 `repoRoot`、`instanceId` 和服务级 dataDir 身份。 +- 处理:先读取 `.app/dev-stack.json`,核对顶层 `repoRoot + instanceId`,再核对服务 `repoRoot + instanceId + dataDir + pid + port`;AGC Vite marker 还必须带 `repoRoot + processId + port`。任何字段缺失或不匹配都拒绝静默复用,改为启动当前工作树自己的服务或明确提示清理。 +- 验证:`scripts/dev.test.ts`、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts` 覆盖 snapshot identity 和旧状态拒绝复用;运行时记录实际端口、进程命令行和 dataDir,不要只记录 HTTP 200。 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 32cff2323..dcc0aa077 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -51,7 +51,7 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐 ## AGC DirectProject 与 UI workflow -- 新 Web 游戏为 `game/` 下的 npm + Vite + Phaser 4.2.1 工程,使用包导入且允许其它依赖;npm 预览与导出只读取 dist,运行素材需纳入构建,已有单 HTML/Godot 不自动迁移。 +- 新 Web 游戏为 `game/` 下的 npm + Vite + Phaser 4.2.1 工程,使用包导入且允许其它依赖;npm 预览与导出只读取 dist,运行素材需纳入构建。单 HTML → Phaser 迁移固定走 DirectProject:文件落盘后先用受控 `project.bootstrap` 在 `game` 执行无参数 `npm install`,再用支持相对 cwd 的 `project.verify` 构建并确认 `game/dist/index.html`,已有单 HTML/Godot 不通过 JSON Generator 伪装成 npm 工程。 - 通用 Agent Rust 分层为 `agent-runtime-core`(catalog、执行生命周期、ToolHost/spawn/all-join/Provider 契约)、`agent-runtime-orchestration`(动态无环任务图、ready、依赖波次、返工下游闭包和受限自主扩图提案)与 `platform-agent` 游戏适配器;循环返工通过新 pass / epoch 表达,不在单张依赖图中建立回边。LLM 可经宿主结构化 function call 提出新增节点/边,编排层只生成经校验的新候选图,epoch 与持久化仍由宿主掌控。 - DirectProject 始终连接客户端内置的 `agc_tools` STDIO MCP,并在启动时额外读取客户端扩展仓库中已启用的第三方 MCP 独立项。第三方 STDIO/HTTP 配置只写入本次隔离 `CODEX_HOME`,单项非 required,启停、重命名和内容指纹进入 app-server pool identity;完整 Plugin Runtime、hooks/apps 和单文件脚本手动指定入口仍关闭。Skill 正文与 references 由 Codex 原生按需读取;`agc_tools` 负责标准美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive、已登记图片去背景、desktop/mobile 浏览器试玩和受控 `agc_web_search`;付费资源调用仍由客户端绑定回合、幂等账本、请求上限和投影权威。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index f8e9416ec..9a6daf398 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -40,9 +40,9 @@ DirectProject 自身的 `read_direct_project_conversation` 也必须在 blocking 新建 Web 游戏使用 npm 工程:默认 `game/package.json` 声明 Phaser 4.2.1 和 Vite 构建工具,源码使用 `import Phaser from 'phaser'`,`package-lock.json` 由 npm 维护。默认脚手架文件位于 `game/`,包含 `index.html`、`game.js`、`style.css`、`vite.config.js` 和 npm 配置/锁文件;已有根目录 npm 工程沿用原根,可按需求拆分模块并添加任意其它 npm 依赖,不设置包名白名单。客户端不手工分发 Phaser bundle,不用 import map 模拟 package 导入。 -Agent 在包含 `package.json` 的目录执行 `npm ci`(依赖变更使用 `npm install`)和 `npm run build`;默认可从工作区根执行 `npm --prefix game ci` 与 `npm --prefix game run build`。DirectProject 保持 workspace-write 目录边界并开放网络,以支持 npm 依赖解析和安装;凭据仍由客户端代理持有,不进入项目或原生命令环境。其它执行模式维持现有权限。新游戏完成后执行 `npm run build` 并用真实浏览器试玩;依赖安装与构建失败必须反馈真实错误,不能回退成未解析裸模块导入的静态页面。 +Agent 在包含 `package.json` 的目录执行 `npm ci`(依赖变更使用 `npm install`)和 `npm run build`;默认可从工作区根执行 `npm --prefix game ci` 与 `npm --prefix game run build`。DirectProject 保持 workspace-write 目录边界并开放网络,以支持 npm 依赖解析和安装;凭据仍由客户端代理持有,不进入项目或原生命令环境。依赖初始化必须通过受控 `project.bootstrap`(固定 `cwd=game`、无参数 `npm install`、只读取 package/lock 并记录 SHA-256 指纹),不得放开通用 `command.exec npm install`。构建使用 `project.verify` 的相对 cwd(例如 `cwd=game`);`build` 通过后还必须存在 `game/dist/index.html` 才能签发验证凭证。其它执行模式维持现有权限。新游戏完成后执行 `npm run build` 并用真实浏览器试玩;依赖安装与构建失败必须反馈真实错误,不能回退成未解析裸模块导入的静态页面。 -npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.html`,静态 smoke 校验构建入口及本地文件引用,实际可玩性由浏览器验证。预览服务与导出读取该构建目录,所有运行素材必须由构建纳入 dist;npm 预览不回退读取源码或项目素材目录,保证试玩与导出一致。源码投影包含 package、锁文件、配置和真实游戏源码,排除 `node_modules/`、`dist/`、控制文件与凭据;npm 试玩包将 dist 文件映射到 `game/` 并附带发布说明,不包含源码依赖安装目录。现有单 HTML 项目不自动迁移,Godot 项目保持原合同。旧 JSON Generator 仅接受已初始化的单 HTML 项目,npm 项目或新建请求在调用 LLM 前明确拒绝并引导使用 DirectProject,避免静态草案伪装为 npm 构建产物。本节覆盖下文仅适用于旧单 HTML 产物的 Canvas API、手写动画循环和禁止外部本地脚本要求。 +npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.html`,静态 smoke 校验构建入口及本地文件引用,实际可玩性由浏览器验证。预览服务与导出读取该构建目录,所有运行素材必须由构建纳入 dist;npm 预览不回退读取源码或项目素材目录,保证试玩与导出一致。源码投影包含 package、锁文件、配置和真实游戏源码,排除 `node_modules/`、`dist/`、控制文件与凭据;npm 试玩包将 dist 文件映射到 `game/` 并附带发布说明,不包含源码依赖安装目录。单 HTML → Phaser 迁移必须在 DirectProject 中完成:先识别 `game/index.html`,再将状态、输入、敌人/守卫、波次、胜负、重开和画布绘制迁移到 Phaser Scene 与 update 循环,并落盘 `game/package.json`、`game/game.js`、`game/style.css`、`game/vite.config.js` 和锁文件;不得把迁移目标伪装成 `gameHtml`。现有单 HTML 项目不自动迁移,Godot 项目保持原合同。旧 JSON Generator 仅接受已初始化的单 HTML 项目,npm 项目或新建请求在调用 LLM 前明确拒绝并引导使用 DirectProject,避免静态草案伪装为 npm 构建产物。本节覆盖下文仅适用于旧单 HTML 产物的 Canvas API、手写动画循环和禁止外部本地脚本要求。 ## 2026-09-02 项目名称显示与自动提炼 @@ -1369,3 +1369,8 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 - **成功后行为**:重读 manifest,**不切换版本**(没有新版本可切),**不自动重载 / 重启运行中的预览**(PRD §3.2 末条),不做运行时资源重映射。可见变化只有资源卡「当前使用」高亮移到替换素材、`@` 面板「当前版本素材」更新。 - **已知代价(用户已确认接受)**:**替换历史不可回溯**——替换前身份只剩那条审计与 manifest 的 `.previous` 副本;需要"某版本历史上换过什么"时要另立切片(PRD §3.2 / §5.3 保留为未来合同正是为此)。 - **验证**:绑定改写通道定向 7 条(`project/manifest/version_binding_rewrite_tests.rs`)+ 替换定向 8 条 + `shared-contracts` 20 条。**变异验证**:① 去掉「未放行版本整条相等」→ 两周转红;② 放行集合改成整个版本数组 → 「未放行版本」转红;③ 去掉长度检查 → 「不增不删」转红;④ 准入删掉 category → 硬门禁与候选两条转红;另有 ⑤ 改回"原地改既有版本但绕过放行口"→ 被「项目版本记录写入后不可修改、删除或重排」拦下。前端 13 条(模型 9 + 真链路 4)。AGC 全量 1231 passed / 4 skipped / 0 failed;共享美术画布组件 1385 passed;`npm run ai-game-creator-shell:typecheck`(含 check-config)、`cargo check --locked --all-targets`、`npm run check:encoding`、`git diff --check` 全绿。`/api/external/v1`、SpacetimeDB schema、`packages/shared` 与 `shared-contracts` 的 wire DTO、manifest 结构、布局 sidecar schema 均未改动。 + +## 2026-09-14 项目客户端占用锁收敛 + +项目锁职责收敛为“客户端占用项目”这一事实:同一客户端进程内的嵌套项目写入调用复用已有项目锁并返回 advisory guard,不再等待自身持有的 `.agent/project.lock`;跨进程竞争继续沿用现有占用、残留回收和权限分类。Runner 的 `.agent/runtime/execution-owner.lock` 迁移到统一项目占用锁仍属于进行中的里程碑,完成前不改变其恢复诊断合同。 + diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index a1bdae5e9..25f02695a 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -36,7 +36,7 @@ npm run dev - 主站 Vite。 - 后台 Vite。 -`npm run dev` 和单模块 `npm run dev:web`、`npm run dev:api-server`、`npm run dev:bgfilter-worker`、`npm run dev:spacetime`、`npm run dev:admin-web` 启动后都会更新根目录 `.app/dev-stack.json`。该文件记录本次命令、数据库、更新时间,以及 `spacetime`、`api-server`、`bgfilter-worker`、`web`、`admin-web` 的 `pid`、监听 host / port、可访问 URL、启动状态和当前命令。`.app/` 是本地运行态目录,不提交 Git;端口漂移、服务重启或子进程退出后以该文件里的实际状态为准。 +`npm run dev` 和单模块 `npm run dev:web`、`npm run dev:api-server`、`npm run dev:bgfilter-worker`、`npm run dev:spacetime`、`npm run dev:admin-web` 启动后都会更新根目录 `.app/dev-stack.json`。该文件记录本次命令、数据库、更新时间,以及 `spacetime`、`api-server`、`bgfilter-worker`、`web`、`admin-web` 的 `pid`、监听 host / port、可访问 URL、启动状态和当前命令;稳定版状态还记录顶层 `repoRoot + instanceId`,每个服务记录 `repoRoot + instanceId + dataDir`,与端口组成复用身份。`.app/` 是本地运行态目录,不提交 Git;端口漂移、服务重启或子进程退出后以该文件里的实际状态为准。缺少身份字段或身份不匹配的旧状态不得被 AGC 静默复用。 通过 `nohup` 在仓库根目录启动 dev 栈且未显式重定向 stdout / stderr 时,默认 `nohup.out` 会持续收集 SpacetimeDB、api-server、bgfilter-worker、主站 Vite 和后台 Vite 的整套 dev 栈输出;该文件已被主站 Vite watcher 和 Git 忽略,避免日志追加触发页面刷新循环,重启主站 Vite 后生效。若把输出显式重定向到其它仓库内文件(例如 `> dev.out`),该自定义文件不会自动获得同样的 watcher 保护,应改为写到 Vite root 之外,或同步配置精确的忽略规则。 @@ -62,7 +62,7 @@ Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块 后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 -AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。端口健康不等于归属正确:复用前还必须证明端口上的监听进程属于当前工作树(Windows 按 `server-rs/target/debug/api-server.exe` 绝对路径与 SpacetimeDB `--data-dir` 校验,探测不可用时退化为旧行为),无法证明归属时一律不复用,改为启动本工作树自己的后端并在需要时端口漂移;否则上个工作树 Ctrl+C 残留的后端会被当成自己的后端复用,改了数据库的工作树会连到旧库。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。 +AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。端口健康不等于归属正确:复用前还必须证明端口上的监听进程属于当前工作树(Windows 按 `server-rs/target/debug/api-server.exe` 绝对路径与 SpacetimeDB `--data-dir` 校验,探测不可用时退化为旧行为),无法证明归属时一律不复用,改为启动本工作树自己的后端并在需要时端口漂移;否则上个工作树 Ctrl+C 残留的后端会被当成自己的后端复用,改了数据库的工作树会连到旧库。启动器在创建原生窗口前预检最终地址;AGC Vite marker 同时提供 `repoRoot + processId + port`,与 `.app/dev-stack.json` 的 `instanceId` 和 API target 交叉核对;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。 Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 diff --git a/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md new file mode 100644 index 000000000..bf3a532ff --- /dev/null +++ b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md @@ -0,0 +1,85 @@ +# 【技术方案】AGC 客户端稳定版生命周期大切换 + +更新时间:`2026-09-14` + +## 目标 + +在 AGC 尚未对外发布的前提下,统一现役客户端入口的异步操作生命周期,并删除会继续制造重复状态机的旧局部防重路径。现有本地项目、`.agent` 文件、Runner 账本和公开后端契约继续作为迁移边界。 + +## 非目标 + +- 不删除或重排本地 manifest、会话、资源生成账本和 Runtime journal。 +- 不删除 `/api/external/v1`、账号、编辑器素材、生成接口、SpacetimeDB schema 或共享 DTO。 +- 不把新玩法、插件、支付、编辑器大功能和主站架构调整并入稳定版切换。 +- 不自动重放无法确认已经受理的 pending 外部操作。 + +## 新的内部合同 + +### ClientOperation + +所有 renderer 异步入口必须能投影以下字段:`operationId`、`requestId`、`kind`、`phase`、`startedAt`、`deadlineAt`、`scope`、`cancelled`。phase 使用 `idle / network / runner / project / success / retryable-failure / unknown`;迟到结果只能在 operationId、scope 和 session generation 都仍匹配时写回。 + +### AuthTransition / RunnerTransition + +启动恢复、手工登录、发送验证码、退出和 401 refresh 共享认证 operation 身份与 session generation。Runner 安装/清除在 blocking worker 执行;前端只展示 network/runner/success/retryable-failure/unknown,并提供重试或重新登录。 + +### HomeCreationOperation + +首页创建由 Launcher controller 持有,payload 保留 draft 快照和 startMode,scope 在建项后绑定 projectPath;创建、附件导入、首轮投递分别推进 phase。页面卸载不取消 operation;成功、可重试失败和不确定状态均可被重新投影。 + +### RecentProjectInspection + +每个最近项目拥有独立 operation 和结果。检查中、可打开、失败、超时、不存在、非目录、未初始化和可导入不能通过一个全局刷新 gate 互相覆盖。 + +### DevStackIdentity + +`.app/dev-stack.json` 和服务 marker 必须包含 `repoRoot + processId + port + dataDir + instanceId` 可验证的身份信息。客户端发现状态时,数据库名、dataDir、repoRoot 或进程归属不匹配就拒绝复用并启动/提示当前工作树自己的服务。 + +## 本地数据迁移 + +- 旧 manifest、项目资源、Planning/GDD、会话和 Runtime journal 继续按现有读取与恢复规则打开。 +- 缺少 operationId 的旧 pending 记录只能转换为可重试或 `unknown`,不自动重放外部副作用。 +- 旧 Runner endpoint 只有在当前 dataDir、repoRoot、进程归属和协议/可用性都匹配时才能复用。 +- 迁移失败保留原文件并给出可操作错误,不以新状态覆盖旧数据。 + +## 任务列表与验收顺序 + +1. **操作合同**:建立共享 `ClientOperation` 类型、状态转移和 stale-result 规则;认证 refresh 与首页创建接入。 +2. **认证/Runner**:统一登录恢复、手工登录、退出、401 refresh 的 operation 投影,保留 session generation 和 blocking worker。 +3. **入口迁移**:首页创建、手动打开、附件导入、Planning V2、DirectProject、资源生成和预览入口复用 operation scope,不再新增 component-level busy ref。 +4. **开发栈身份**:dev-stack snapshot、Vite marker、AGC 配套后端复用门禁统一使用 repoRoot/processId/port/dataDir/instanceId。 +5. **本地恢复**:对旧 pending operation、旧 endpoint 和正在写入的项目执行失败关闭、可重试或人工核对迁移。 +6. **旧路径清理**:仅删除无现役调用方、无持久化合同、无公开契约的旧分支;每次删除前补调用方和持久化证据。 +7. **完整验收**:启动、登录、进入首页、打开项目、对话、确认、资源、预览、切项目、恢复对话/运行态、失败重试、退出登录,并验证坏项目、迟到结果、Runner 重复启动和旧 worktree 串用门禁。 + +## 当前实现状态 + +- HTTP body-aware timeout、refresh singleflight 清理、Runner blocking worker、最近项目逐项检查和首页创建跨页防重已完成并在 PR #346 中提交。 +- `ClientOperation` 基础合同、认证 refresh 与 Runner auth-transition operation 投影、首页创建 operation 投影和 dev-stack `instanceId` 已在本轮切换中落地。 +- 稳定版基础切换里程碑已完成;后续入口只允许复用该合同,不再新增 component-level busy/ref 状态机。 +- Planning/DirectProject/资源生成/预览已有各自 durable operation 或 request scope;本轮只补统一投影与身份校验,不重写其持久化账本。 + +## 现役边界审计 + +以下能力在本次切换前已经具备 durable 恢复或失败关闭合同,因此本轮按现有实现接入统一投影,不重复重写: + +| 边界 | 当前证据 | 切换结论 | +| --- | --- | --- | +| 本地 manifest、Godot/Cocos 导入和项目 revision | `src-tauri/src/project/manifest/`、`import_tests.rs`、`recovery_tests.rs` | 保留旧文件格式,失败关闭,不复制平行项目根 | +| 资源生成与 pending operation | `src-tauri/src/project/resource_editor.rs`、`generation/*` 测试 | 已有 operation/幂等/needs-reconciliation,禁止未知结果自动重放 | +| Agent Runtime、Planning V2、DirectProject | `runtime_driver/recovery_scan.rs`、`runtime_protocol/`、`direct_runtime.rs` | 继续使用 durable task/session/run,统一 renderer operation 投影 | +| 公开后端与共享契约 | `server-rs`、`packages/shared`、`docs/openapi` | 不删除、不改公开契约 | +| 已退役客户端入口 | 当前 `WorkspaceLauncher`、`App.tsx` 和路由调用方审计 | 无现役调用方的旧分支才允许后续删除,暂不以猜测删除 | + +完整真实 Provider、原生安装包和跨重启端到端时序仍需在具备登录/Provider 的环境中执行;本次代码门禁已覆盖 deterministic surface、operation、认证和 dev-stack identity。 + +## 验收证据 + +| 条款 | 证据 | +| --- | --- | +| operation identity 与 stale-result | `clientOperation.test.ts`、认证/home 定向测试 | +| HTTP/auth/Runner | client HTTP/API 测试、Rust cargo check、认证 appSurface | +| 最近项目逐行刷新 | `recentProjectsModel.test.ts`、home appSurface | +| dev-stack 身份 | `scripts/dev.test.ts`、`start-dev-stack.test.ts`、端口 marker 检查 | +| 本地恢复边界 | 现有 manifest/runtime/resource recovery tests;未确认外部副作用不自动重放 | +| 完整流程 | AGC appSurface、开发栈 smoke;真实 Provider/安装包另行记录 | diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index 130d270cd..04da16962 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -34,7 +34,8 @@ describe('AI 游戏创作 App 共享契约', () => { it('keeps command permissions explicit', () => { const commandIds = GAME_CREATION_APP_COMMANDS.map((command) => command.id); - expect(GAME_CREATION_APP_COMMANDS).toHaveLength(65); + expect(GAME_CREATION_APP_COMMANDS).toHaveLength(66); + expect(commandIds).toContain('project.bootstrap'); expect(commandIds).toContain('project.git_inspect'); expect(commandIds).toContain('project.git_commit'); expect(commandIds).toContain('project.patchset'); diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index e262a4f81..a6f568836 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -25,6 +25,7 @@ export const GAME_CREATION_APP_COMMANDS = [ { id: 'project.patchset', permission: 'confirm' }, { id: 'project.restore', permission: 'confirm' }, { id: 'project.verify', permission: 'confirm' }, + { id: 'project.bootstrap', permission: 'confirm' }, { id: 'project.export_package', permission: 'confirm' }, { id: 'project.export_list', permission: 'auto' }, { id: 'project.policy_read', permission: 'auto' }, diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 964cba65b..8b13cb61b 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -425,6 +425,7 @@ function buildDevStackSnapshot(runner, updatedAt = new Date().toISOString()) { return { schemaVersion: 2, + instanceId: runner.instanceId, command: runner.command ?? 'all', repoRoot, database: runner.options.database, @@ -450,6 +451,12 @@ function buildDevStackServiceSnapshot(runner, serviceName, updatedAt) { : null; return { + repoRoot, + instanceId: runner.instanceId, + dataDir: + serviceName === 'spacetime' + ? resolve(runner.options.spacetimeDataDir) + : null, status, pid: childPid ?? @@ -1198,6 +1205,9 @@ class DevRunner { constructor(options, baseEnv = process.env, explicitOptions = new Set()) { this.options = options; this.baseEnv = { ...baseEnv }; + this.instanceId = `dev-${process.pid}-${randomBytes(12).toString('hex')}`; + this.baseEnv.GENARRATIVE_DEV_STACK_INSTANCE_ID = this.instanceId; + this.baseEnv.GENARRATIVE_DEV_STACK_REPO_ROOT = repoRoot; this.spacetimeApiToken = String( this.baseEnv.GENARRATIVE_SPACETIME_TOKEN ?? '', ).trim(); diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index fd932b81b..d7da71610 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -723,6 +723,7 @@ describe('dev scheduler stack state file', () => { test('状态快照记录服务 pid、端口、URL 和当前命令', () => { const updatedAt = '2026-05-29T00:00:00.000Z'; const runner = { + instanceId: 'test-instance', command: 'web', options: { apiHost: '127.0.0.1', @@ -770,12 +771,16 @@ describe('dev scheduler stack state file', () => { const snapshot = buildDevStackSnapshot(runner, updatedAt); expect(snapshot.schemaVersion).toBe(2); + expect(snapshot.instanceId).toBe('test-instance'); expect(snapshot.command).toBe('web'); expect(snapshot.database).toBe('genarrative-test'); expect(snapshot.spacetimeDataDir).toBe( resolve('server-rs/.spacetimedb/local/data'), ); expect(snapshot.services.web).toMatchObject({ + repoRoot: resolve('.'), + instanceId: 'test-instance', + dataDir: null, status: 'running', pid: 4321, host: '0.0.0.0', diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index d3d4768f1..9c8a6b62d 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -21,7 +21,7 @@ pub struct GameCreationAppCommandDescriptor { pub permission: GameCreationAppPermission, } -pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 65] = [ +pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 66] = [ command("help.show", GameCreationAppPermission::Auto), command("project.create", GameCreationAppPermission::Confirm), command("project.rename", GameCreationAppPermission::Confirm), @@ -34,6 +34,7 @@ pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 65] = [ command("project.patchset", GameCreationAppPermission::Confirm), command("project.restore", GameCreationAppPermission::Confirm), command("project.verify", GameCreationAppPermission::Confirm), + command("project.bootstrap", GameCreationAppPermission::Confirm), command("project.export_package", GameCreationAppPermission::Confirm), command("project.export_list", GameCreationAppPermission::Auto), command("project.policy_read", GameCreationAppPermission::Auto), @@ -1236,7 +1237,7 @@ mod tests { #[test] fn command_contract_keeps_expected_permissions() { - assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 65); + assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 66); let command_ids = GAME_CREATION_APP_COMMANDS .iter()