From 32714298beb39d7daaeef2602c497a08488facca Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 7 Oct 2026 01:08:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(agc):=20fork=20=E5=BB=BA=E9=A1=B9=E6=8A=8A?= =?UTF-8?q?=E6=88=90=E5=93=81=E5=8C=85=E9=93=BA=E8=BF=9B=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=A0=B9=E5=B9=B6=E7=99=BB=E8=AE=B0=E5=8F=AF=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E5=8E=9F=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/src/game_fork.rs | 245 +++++++++++++----- .../src-tauri/src/project/export.rs | 2 +- 2 files changed, 175 insertions(+), 72 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs b/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs index 8e17d5009..d14089fdc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs @@ -1,16 +1,21 @@ -//! 从平台作品 Fork:受鉴权取件 → 摘要校验 → 建成一份可在本项目里继续改造的工程。 +//! 从平台作品 Fork:受鉴权取件 → 摘要校验 → 建成一份可在本项目里继续改造、并且**立刻可运行** +//! 的工程。 //! //! 两种取件形态(服务端 `source`)落法不同,实现事实如下: -//! - `Project`(工程源包):包本身就是作者的工程,解到项目根即可继续改造; -//! - `Package`(已构建成品包):平台只有成品时,先按标准初始化生成脚手架,再把成品放进 -//! `/reference//`,用户在新工程里继续改造。 +//! - `Project`(工程源包):包本身就是作者的工程,解到项目根即可继续改造。包内**不含**构建产物 +//! (`project_bundle.rs` 排除 `game/dist`),所以本机还没有可运行的产物:建项只登记一条初始 +//! 工程版本,`code-prototype` 保持 `pending`,由运行视图如实提示「需要先构建」。 +//! - `Package`(已构建成品包):平台只有成品时,先按标准初始化生成脚手架,再把成品铺进**预览根** +//! (`fork_playable_root`,即 `game/dist`)——运行视图只服务预览根,铺在别处的副本用户既看不到 +//! 也播不了。成品包自带可玩入口,因此建项同时把 `code-prototype` 登记为已完成。 //! //! 关键顺序(顺序本身就是合同): //! 1. 先取件并按元数据校验字节(失败关闭,不落盘); //! 2. 再 `init_local_game_project_at` 生成脚手架——它的 `create_npm_scaffold` 判据是 //! 「没有 manifest 且根/`game` 都没有 index.html、没有 package.json」,所以 `Package` -//! 形态的成品必须等脚手架生成之后再铺,且只能铺进子目录; -//! 3. 最后写 `.agent/fork-source.json`,供发布链路在首次发布时声明 Fork 来源。 +//! 形态的成品必须等脚手架生成之后再铺,且不能铺进项目根或 `game/` 根; +//! 3. 然后 `register_forked_project_state_at` 登记「可运行原型」与初始工程版本; +//! 4. 最后写 `.agent/fork-source.json`,供发布链路在首次发布时声明 Fork 来源。 #[cfg(not(test))] mod desktop; @@ -19,9 +24,6 @@ pub(crate) use desktop::*; use super::*; -/// 参考副本的子目录名。绝不能落在项目根或 `game/` 根:那会让合规脚手架一个文件都不生成。 -const FORK_REFERENCE_DIRECTORY_NAME: &str = "reference"; - /// 取件字节校验:字节数与 SHA-256 都必须与取件元数据一致,任一不符即失败关闭。 /// /// 抽成纯函数是为了让「失败就绝不落盘」这条规则可被单测钉住:调用点在解压之前。 @@ -43,39 +45,75 @@ pub(crate) fn verify_fork_source_bytes( Ok(()) } -/// 参考副本相对项目的路径:`reference//`。 +/// 可玩参考的落点:项目的**预览根**。 /// -/// 作品 ID 来自平台响应,只接受路径安全取值;拼接复用归档条目门禁(拒绝 `..`、盘符与 -/// 绝对路径),保证参考副本永远落在项目内的子目录,不会逃出项目根。这是参考目录的**唯一** -/// 推导处:调用方把它 join 到项目根,不再另起第二份拼接规则。 -pub(crate) fn fork_reference_relative_path(game_id: &str) -> Result { - let game_id = game_id.trim(); - // 空标识必须显式拒绝:`safe_archive_relative_path` 会把空段丢掉,`reference/` 会被 - // 归一成 `reference`,那不是某个作品的参考目录。 - if game_id.is_empty() { - return Err("Fork 来源作品 ID 无效,无法确定参考副本目录".to_string()); - } - let relative = format!("{FORK_REFERENCE_DIRECTORY_NAME}/{game_id}"); - let relative = crate::template_library::safe_archive_relative_path(&relative) - .map_err(|_| "Fork 来源作品 ID 无效,无法确定参考副本目录".to_string())?; - // 只接受「`reference` + 单一作品目录」两段:带 `/` 的标识会变成嵌套目录,既可能撞上 - // 另一个作品的目录,也不是平台作品 ID 的形状。 - if relative.components().count() != 2 { - return Err("Fork 来源作品 ID 无效,无法确定参考副本目录".to_string()); - } - Ok(relative) +/// 成品包 fork 把取到的发行产物铺在这里,而不是另找一个"参考目录":运行视图只服务 +/// `preview::project_game_root()` 指向的那一个根(`preview.rs` 的 +/// `start_local_game_preview_for_project` / `resolve_preview_path`),铺在根之外的副本用户 +/// 既看不到也播不了——那正是「fork 之后没法直接运行」的成因之一。 +/// +/// 预览根由 `project_game_root` 单点推导,这里不另写第二份判据:本形态必然是刚生成的 npm +/// 脚手架(`game/package.json` 存在),预览根就是 `game/dist`(AGC 网页脚手架的 vite +/// `outDir`)。它**不**在项目根或 `game/` 根,因此不会打断 `create_npm_scaffold` 判据;它也 +/// 被工程源包的排除清单覆盖(`project_bundle.rs` 的 `game/dist`),fork 来的成品**不会**被当成 +/// 作者自己的源码重新上传。 +pub(crate) fn fork_playable_root(project_root: &Path) -> PathBuf { + crate::preview::project_game_root(project_root) } +/// Fork 建项收口:把「本项目已有可运行原型」与初始工程版本**如实**登记进 manifest。 +/// +/// 为什么必须有这一条:运行视图与发布导出都以 `manifest.tasks[code-prototype] == completed` +/// (或存在运行中的预览)作为「项目里已有可运行原型」的**唯一**事实——前端 +/// `view/project-development/index.tsx` 的 `runAvailable`、后端 `project/export.rs` 的 +/// `project_has_runnable_prototype` 都是这一条。Fork 建项过去只生成脚手架与全新 manifest +/// (seed 任务全 `pending`、`versions` 为空),于是 fork 出来的项目一进来就被判成「首个可运行 +/// 原型尚未完成」:运行页签点了没反应、发布也被拦——而它其实已经带了一份可玩的成品。 +/// +/// 写入顺序照既有 AI 直连回合收口(`agent/direct_runtime/mod.rs` 的同一套模式,见 +/// `sync_direct_codex_project_file_projection_at`):任务状态 → 推进 project revision → +/// 追加 `initial-` 版本 → 宣告清单失效。这里不新造状态写入路径,也不动 manifest +/// 契约(改编来源仍只写在 `.agent/fork-source.json`)。 +/// +/// **只在项目里确实存在可玩入口时才标完成**(判据与预览同源:预览根下的 `index.html`): +/// - 成品包形态:发行产物已铺进预览根,`<预览根>/index.html` 存在 → 登记为已完成,运行与 +/// 发布都立刻可用; +/// - 工程源包形态:按产品口径**不自动构建**(与「不自动安装外部工程依赖」的既有策略一致), +/// 本机还没有可运行产物 → 只追加初始工程版本、`code-prototype` 保持 `pending`,由运行视图 +/// 如实提示「需要先构建」,绝不谎报原型已完成。 +/// +/// `emit_game_creator_manifest_invalidated` 不是可选项:资源画布「项目版本」卡与发布面板的 +/// 版本标签只读清单快照,不宣告失效就看不到刚写进去的初始版本。 +fn register_forked_project_state_at(root: &Path) -> Result { + if fork_playable_root(root).join("index.html").is_file() { + update_manifest_task_status_at( + root, + "code-prototype", + GameCreationAppTaskStatus::Completed, + )?; + } + // 顺序是合同:先推进 durable revision,再把该 revision 绑成正式版本 + // (`append_agent_game_iteration_version_at` 明确要求 revision > 0 且由调用方先推进)。 + let revision = advance_agent_runtime_project_revision_locked(root)?; + append_agent_game_iteration_version_at(root, revision)?; + emit_game_creator_manifest_invalidated(root, FORK_MANIFEST_INVALIDATED_SOURCE); + read_manifest_for_project(root) +} + +/// 清单失效事件的来源标识(与 `direct-codex.version` 等既有来源并列,便于排障定位写入者)。 +const FORK_MANIFEST_INVALIDATED_SOURCE: &str = "game-fork.project"; + /// 用平台作品的取件内容建一个新项目。 /// /// 两种取件形态(`source`)落法不同: -/// - `Package`(已构建成品包):先按标准初始化生成脚手架,再把成品铺进 -/// `/reference//`——平台只有成品时只有这一份内容可用。 +/// - `Package`(已构建成品包):先按标准初始化生成脚手架,再把成品铺进**预览根** +/// (`fork_playable_root`)——平台只有成品时只有这一份内容可用,而且它必须落在预览根才播得了。 /// - `Project`(工程源包):包本身就是作者的工程(自带 `package.json` / `vite.config.*`), -/// 因此**先把包解到项目根**再走标准初始化:初始化会跳过 npm 脚手架(`game/package.json` 已存在), +/// 因此**先把包解到项目根**再走标准初始化:初始化会跳过 npm 脚手架(`game/index.html` 已存在), /// 只补 `.agent` 身份、目录与 agent.db。 /// -/// 任一步失败都删掉半成品目录,不留无法解释的项目。 +/// 任一形态建项成功后都走 `register_forked_project_state_at` 收口(登记「可运行原型」与初始 +/// 工程版本),任一步失败都删掉半成品目录,不留无法解释的项目。 #[allow(clippy::too_many_arguments)] pub(crate) fn create_project_from_platform_fork_at( projects_root: &Path, @@ -91,8 +129,10 @@ pub(crate) fn create_project_from_platform_fork_at( let requested_name = requested_name .map(normalize_game_creation_project_name) .transpose()?; - // 目录名先算出来:标识非法时要在创建任何目录之前失败,不能留下半成品。 - let reference_relative = fork_reference_relative_path(game_id)?; + // 作品 ID 来自平台响应:先把形状钉住再创建任何目录,非法标识不得留下半成品。 + if !crate::game_distribution_publish::is_safe_fork_game_id(game_id.trim()) { + return Err("Fork 来源作品 ID 无效,无法建立来源记录".to_string()); + } if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() { return Err("自动工作区根目录必须是绝对路径".to_string()); } @@ -126,7 +166,7 @@ pub(crate) fn create_project_from_platform_fork_at( harden_new_game_creator_private_path(&project_root, true, "自动项目目录")?; enforce_project_permission_policy(&project_root, "project.create")?; let _lock = acquire_project_write_lock(&project_root, "project.create")?; - match source { + let mut project = match source { // 工程源包:包本身就是可编辑工程,先解到项目根再初始化。 ProjectForkSourceKind::Project => { on_stage(ForkSyncStage::Extract); @@ -151,10 +191,11 @@ pub(crate) fn create_project_from_platform_fork_at( ProjectForkSourceKind::Project, ), )?; - Ok(project) + project } - // 成品包:脚手架必须在参考产物之前生成,否则 `create_npm_scaffold` 判据被 - // 成品包里的 `index.html` / `package.json` 打断,项目从此无法发布。 + // 成品包:脚手架必须先生成,否则 `create_npm_scaffold` 判据被成品包里的 + // `index.html` / `package.json` 打断,项目从此无法发布;铺到哪里则由 + // `fork_playable_root` 决定(预览根),不能铺进项目根或 `game/` 根。 ProjectForkSourceKind::Package => { on_stage(ForkSyncStage::Project); let project = init_local_game_project_at( @@ -163,14 +204,17 @@ pub(crate) fn create_project_from_platform_fork_at( &project_name, )?; on_stage(ForkSyncStage::Extract); - let reference_root = project_root.join(&reference_relative); + let playable_root = fork_playable_root(&project_root); ensure_game_creator_private_directory_tree( - &reference_root, - "改编参考目录", + &playable_root, + "可玩参考目录", )?; + // 发行包契约:入口 `index.html` 固定在包根(服务端 `validate_release_zip` + // 与 api-server 的 `package_entry_path` 都强制),所以整包解到预览根后 + // 就是 `<预览根>/index.html`。 crate::template_library::extract_template_archive( package_bytes, - &reference_root, + &playable_root, ) .map_err(|error| format!("Fork 内容解压失败:{error}"))?; write_project_fork_source( @@ -181,9 +225,13 @@ pub(crate) fn create_project_from_platform_fork_at( ProjectForkSourceKind::Package, ), )?; - Ok(project) + project } - } + }; + // 建项收口:登记「可运行原型」与初始工程版本。返回收口**之后**重读的清单, + // 免得把过期快照交给进项目通道(它会据这份清单渲染首帧)。 + project.manifest = register_forked_project_state_at(&project_root)?; + Ok(project) })(); if result.is_err() { let _ = fs::remove_dir_all(&project_root); @@ -443,23 +491,23 @@ mod tests { } #[test] - fn fork_reference_relative_path_stays_inside_the_project() { - // 生产路径就是「把这唯一的相对路径 join 到项目根」,因此这里直接钉相对路径本身。 - assert_eq!( - fork_reference_relative_path("game_1").expect("safe id"), - Path::new("reference").join("game_1") - ); - assert_eq!( - Path::new("C:/work/project").join(fork_reference_relative_path("game_1").unwrap()), - Path::new("C:/work/project/reference/game_1") - ); - // 路径不安全的标识一律失败关闭,不拼出逃出项目根的路径。 - for unsafe_id in ["", "..", "../escape", "a/b", "a\\b", "C:game", " "] { - assert!( - fork_reference_relative_path(unsafe_id).is_err(), - "should reject {unsafe_id:?}" - ); - } + fn playable_reference_root_is_the_project_preview_root() { + // 生产路径把成品包铺到「预览根」。它必须与预览服务读取的根(`project_game_root`) + // 是同一个答案,否则「铺进项目」与「预览服务哪里」会分叉成两份判据——fork 之后能跑 + // 不能跑就取决于这份判据是否一致。 + let root = test_root("playable-root"); + fs::create_dir_all(root.join("game")).expect("create game dir"); + fs::write( + root.join("game/package.json"), + r#"{"scripts":{"build":"vite build"}}"#, + ) + .expect("write package.json"); + + let playable_root = fork_playable_root(&root); + assert_eq!(playable_root, root.join("game/dist")); + // 可玩参考永远落在项目内,不会拼出项目外的路径。 + assert!(playable_root.starts_with(&root)); + fs::remove_dir_all(&root).ok(); } /// 最小工程源包:自带 `game/package.json` 与 `game/index.html`(这正是「解压即工程」的条件)。 @@ -482,7 +530,7 @@ mod tests { } #[test] - fn platform_project_bundle_source_yields_an_editable_project_without_reference_copy() { + fn platform_project_bundle_source_stays_source_only_with_an_initial_version() { let projects_root = test_root("create-project-source"); let bytes = project_bundle_bytes(); let project = create_project_from_platform_fork_at( @@ -516,10 +564,37 @@ mod tests { assert!(root.join(".agent/manifest.json").is_file()); assert!(root.join(".agent/agent.db").is_file()); - // 3) 工程源包不做参考副本:包里就是工程本身。 + // 3) 工程源包不做参考副本,也不在 fork 时构建:包内不含构建产物(工程源包排除 + // `game/dist`),因此本机还没有可运行的产物。 assert!(!root.join("reference").exists()); + assert!(!root.join("game/dist").exists()); - // 4) 来源记录带上取件形态(v2)。 + // 4) 收口**如实**:登记一条初始工程版本,但 `code-prototype` 保持未完成——运行视图会 + // 提示「需要先构建」,绝不谎报原型已完成。 + let code_prototype = project + .manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .map(|task| task.status.clone()) + .expect("code-prototype 必须存在于 seed 任务里"); + assert_eq!(code_prototype, GameCreationAppTaskStatus::Pending); + assert!(!project_has_runnable_prototype(&project.manifest)); + assert_eq!( + project.manifest.versions.len(), + 1, + "fork 建项必须有初始版本" + ); + let initial = &project.manifest.versions[0]; + assert_eq!(initial.version_id, "initial-1"); + assert_eq!(initial.project_revision, 1); + assert_eq!( + initial.created_reason, + GameIterationVersionCreatedReason::Initial + ); + assert_eq!(initial.parent_version_id, None); + + // 5) 来源记录带上取件形态(v2)。 let record = read_project_fork_source(root).expect("fork source record"); assert_eq!(record.schema_version, FORK_SOURCE_SCHEMA_VERSION); assert_eq!(record.source, ProjectForkSourceKind::Project); @@ -528,7 +603,7 @@ mod tests { } #[test] - fn platform_fork_project_gets_scaffold_reference_copy_and_source_record() { + fn platform_fork_project_gets_playable_reference_at_the_preview_root() { let projects_root = test_root("create"); let bytes = release_package_bytes(); let project = create_project_from_platform_fork_at( @@ -550,15 +625,43 @@ mod tests { assert!(root.join("game/index.html").is_file()); assert!(root.join(".agent/manifest.json").is_file()); - // 2) 参考副本落在子目录里,且与脚手架互不覆盖。 - assert!(root.join("reference/game_parent/index.html").is_file()); - assert!(root.join("reference/game_parent/game/main.js").is_file()); + // 2) 成品铺在**预览根**(`game/dist`),与脚手架互不覆盖:预览根下必须有可玩入口, + // 否则运行视图即使放行也会报「游戏目录不存在」。不再另写一份 `reference/` 副本 + // (第二份副本用户既看不到也播不了,还会翻倍占磁盘)。 + assert!(!root.join("reference").exists()); + assert_eq!( + fs::read_to_string(root.join("game/dist/index.html")).unwrap(), + "" + ); + assert_eq!( + fs::read_to_string(root.join("game/dist/game/main.js")).unwrap(), + "console.log('playable')" + ); assert_ne!( - fs::read_to_string(root.join("reference/game_parent/index.html")).unwrap(), + fs::read_to_string(root.join("game/dist/index.html")).unwrap(), fs::read_to_string(root.join("game/index.html")).unwrap() ); - // 3) 来源记录与项目身份一致,并标明这条是成品包取件(v2 字段)。 + // 3) 收口把「可运行原型」登记上:成品包自带可玩入口,所以任务置完成、版本为初始版本, + // 运行与发布门禁同时放行(这就是「fork 之后能直接运行」的判据本身)。 + let code_prototype = project + .manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .map(|task| task.status.clone()) + .expect("code-prototype 必须存在于 seed 任务里"); + assert_eq!(code_prototype, GameCreationAppTaskStatus::Completed); + assert!(project_has_runnable_prototype(&project.manifest)); + assert_eq!( + project.manifest.versions.len(), + 1, + "fork 建项必须有初始版本" + ); + assert_eq!(project.manifest.versions[0].version_id, "initial-1"); + assert_eq!(project.manifest.versions[0].project_revision, 1); + + // 4) 来源记录与项目身份一致,并标明这条是成品包取件(v2 字段)。 let record = read_project_fork_source(root).expect("fork source record"); assert_eq!(record.source, ProjectForkSourceKind::Package); assert_eq!(record.game_id, "game_parent"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs index 4896eaa65..57b789914 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs @@ -132,7 +132,7 @@ pub(crate) fn export_local_project_package_at( /// 给足时间但必须有界),避免发布路径越过校验器允许的区间。 pub(crate) const PUBLISH_BUILD_TIMEOUT_SECONDS: u64 = 300; -fn project_has_runnable_prototype(manifest: &GameCreationAppManifest) -> bool { +pub(crate) fn project_has_runnable_prototype(manifest: &GameCreationAppManifest) -> bool { let has_completed_prototype_task = manifest.tasks.iter().any(|task| { task.id == "code-prototype" && task.status == GameCreationAppTaskStatus::Completed });